Orchestrate

SAP tools for agents

Wrap SAP APIs as agent tools that read in the user's scope, validate every input, log every call, draft changes idempotently and fit SAP's API policy.

Updated Oct 6, 2026Foundational 9 minDeep 40 min
Foundational layer · 9 min read

The 60-second version

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:

  1. 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.
  2. 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.
  3. Check every input in code. The model's arguments are suggestions. Your code checks them before anything reaches SAP.
  4. Make changes safe to repeat. Networks fail and agents retry. A repeated request must not create a second order or a second release.
  5. 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.

Why it matters to the business

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.

How SAP does it

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.

Read, draft, change: a decision guide

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.

Questions to ask

  • Which SAP APIs does each tool call, and are they published on the SAP Business Accelerator Hub?
  • Whose identity does each tool use in SAP: the user's, or a technical user? If technical, what can that user see and change?
  • Can any tool change SAP data? Where is the approval enforced: in code, or only in the prompt?
  • What happens if the same request arrives twice?
  • Can we answer "who asked the agent to do this, and who approved it?" from our logs for any change?
  • How does our design fit SAP's current API policy, and who confirmed that with SAP?
  • What limits stop a looping agent from flooding SAP with calls?

Common misconceptions

  • "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.

Key terms

  • Read tool / draft tool / change tool: tools that look up, prepare a request for approval, or change data.
  • Technical user: a system identity an application signs in with, not a person.
  • Principal propagation: passing the signed-in user's identity through to SAP, so SAP's own authorizations apply to each call.
  • Idempotent: safe to repeat; doing it twice has the same effect as doing it once.
  • Idempotency key: a unique value sent with a request so the server can recognize a repeat.
  • Audit log: a tamper-resistant record of who did what, when.
  • Excessive agency: OWASP's name for an AI system with more functions, permissions or autonomy than its job needs.
  • SAP-endorsed architecture: a route SAP names for agentic access to its APIs, such as the MCP Gateway or Joule agents.

Check yourself

Pick one answer for each question. The explanation appears after you choose.
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
Deep layer · 40 min read

Mental model: the tool is a gateway, not a pipe

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.

How it works

Read tools and change tools

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.

Whose identity reaches SAP

There are three options, from weakest to strongest:

  1. 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.
  2. 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.
  3. 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.

Validate in code, not in the schema alone

The tool's schema tells the model what to send. Your code checks what it actually sent. The lab's gateway checks, before any SAP call:

  • No unknown arguments. A model that invents sales_org to widen its scope gets an error.
  • Formats. Order numbers are 1 to 10 digits. That also blocks a value like 4711') or ('1 from reaching a URL.
  • Ranges. limit is 1 to 20; a model that asks for 500 is told the range.
  • Lengths. A justification is 20 to 500 characters.

Arguments the model must not choose are not arguments at all. The user's sales organizations come from the session, never from the model.

Errors that help without leaking

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.

Idempotent drafts and changes

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

Audit logging

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.

Limits

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.

The API policy as a design input

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.

Before you start: complete Set up your computer for this course and Set up for Unit 9, which creates the unit09 folder. For the sandbox run, you need the SAP_API_KEY from the first setup topic; Calling your first SAP API explains the API. This walkthrough doesn't repeat those steps.

What you need

  • Your course folder with its .venv. No new libraries: requests and python-dotenv are already installed.
  • About 40 minutes.
  • Free. The --sample runs need no account and no internet. The sandbox run needs your free SAP Business Accelerator Hub key.

Step 1: Open your course folder and turn on the virtual environment

  1. Open VS Code, choose File > Open Folder, and open orchestrate-course.

  2. Open a terminal: Terminal > New Terminal.

  3. If the prompt doesn't start with (.venv), turn it on:

    • Windows (PowerShell):

      .venv\Scripts\Activate.ps1
    • macOS / Linux:

      source .venv/bin/activate

Step 2: Keep the log and drafts out of Git

The script writes an audit log and a drafts file. Both can hold business details, so they don't belong in your repository.

  1. In VS Code, open .gitignore in your course folder.

  2. Add these two lines at the end and save:

    unit09/tool_audit.jsonl
    unit09/release_drafts.json

Step 3: Save the script

  1. In VS Code's file list, right-click unit09, choose New File and name it sap_tools.py.
  2. 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()

Step 4: See what the model would be offered

Run (the same on every system):

python unit09/sap_tools.py tools --user ana

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.

Step 5: Run the demo on sample data

python unit09/sap_tools.py demo --sample

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.

Step 6: Read the audit log

python unit09/sap_tools.py audit

What success looks like:

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.

Step 7: Call one tool yourself

Arguments are written as key=value, which works the same in PowerShell and in macOS or Linux terminals.

python unit09/sap_tools.py call sales_order_get sales_order=4711 --sample
python unit09/sap_tools.py call sales_order_list_blocked customer=17100001 --user ben --sample
python unit09/sap_tools.py call sales_order_get sales_order=4711 color=red --sample

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.

Step 8: Run against SAP's sandbox

  1. Check that .env in your course folder has a line SAP_API_KEY="...". If not, follow the key steps in Set up your computer for this course.

  2. Run the demo without --sample:

    python unit09/sap_tools.py demo

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.

Step 9: Save your work in Git

git add unit09/sap_tools.py .gitignore
git commit -m "Unit 9: SAP tools with a gateway"

What each part of the script does

Part What it does
LiveSAP, SampleSAP Send GET requests to SAP's sandbox, or answer the same way from made-up data. Nothing else talks to SAP
USERS Made-up people with sales organizations and roles. In production this comes from the user's sign-in
@tool(...) Registers a tool: what the model sees (name, description, schema) and what only your code sees (kind, roles, SAP API)
sales_order_list_blocked Filters by the user's sales organizations in the query, then checks each result again
sales_order_get Reads one order and its items through read_visible_order, which gives one answer for "missing" and "not yours"
release_request_draft Saves a draft keyed by user, action and order; a repeat returns the same draft
Session.offered_tools Gives each user only the tools their roles allow
Session.call The gateway: offered? valid? within budget? then run, catch errors and log
validate Checks the model's arguments against the schema: unknown names, formats, ranges, lengths
Session.audit Appends one JSON line per call: who, what, outcome, SAP requests, duration
demo Plays the agent: good calls, typical mistakes, a retry and a denied tool

If something goes wrong

What you see What it means What to do
python is not recognized / command not found Python isn't on your path, or the environment is off Turn on .venv (Step 1). On macOS or Linux try python3. See the setup topic
ModuleNotFoundError: No module named 'requests' The library isn't installed in this environment Turn on .venv, then pip install requests python-dotenv. --sample works without it
SAP_API_KEY is not set No key in .env, or you ran from another folder Run from orchestrate-course, check .env, or use --sample
SAP could not answer (HTTP 401 ...) The key is missing, wrong or incomplete Copy it again from the SAP Business Accelerator Hub and update .env
SAP could not answer (could not reach sandbox.api.sap.com ...) Your network or a company proxy blocks the site Try another network, or ask IT to allow sandbox.api.sap.com. --sample still works
Unknown user 'anna' --user must be one of the made-up users Use ana, ben or kim
Write arguments as key=value An argument without = Write sales_order=4711, not 4711
Every demo run says "duplicate": true The draft from an earlier run is still in release_drafts.json That is the idempotency working. Delete the file to start fresh

The SAP way

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.

Endorsed routes for agents

SAP's API policy restricts agentic use of its APIs except through endorsed routes. The routes named in SAP's July 2026 briefing to ASUG:

Route What it is Status reported
A2A into Joule agents Your agent hands work to a Joule agent through the A2A protocol SAP's preferred route, per ASUG
MCP Gateway (SAP Integration Suite) Exposes APIs as MCP tools with central policies Released 5 July 2026 in AWS and Azure data centers, per ASUG
SAP Business Data Cloud Data products for analytics and extraction Covered in Unit 7
Agent Gateway Central authentication, principal propagation and policy enforcement for agents Still forthcoming in July 2026; SAP's Agent Identity page dates its enforcement point to H2 2026

MCP Gateway

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.

Identity

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.

Approval inside SAP

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: the change step after approval

# 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.

Audit logging on BTP

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.

Build vs. SAP

Need Build it yourself SAP
Expose SAP APIs as agent tools 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.

Production concerns

  • 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.

Pitfalls

  • 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.

  1. Open unit09/sap_tools.py.

  2. 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:

    SAMPLE_CHILDREN["to_Partner"] = {
        "4711": [{"PartnerFunction": "SP", "Customer": "10100001"},
                 {"PartnerFunction": "SH", "Customer": "10100009"}],
    }
  3. 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]}
    
    
  4. Save the file and check that the auditor now gets three tools:

    python unit09/sap_tools.py tools --user kim
  5. 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
  6. Run python unit09/sap_tools.py audit and find your three calls.

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

  8. 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.

Check yourself

Pick one answer for each question. The explanation appears after you choose.
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.

Sources

Sign in to track your progress

We'll email you a one-time sign-in link. No password needed.

or

Tell us a little about you

Optional, every field. It helps us pitch answers to your questions at the right level and decide which topics to write next. It is never shown publicly, and you can change or clear it anytime from the account menu.

SAP areas you work in