I Asked Claude Code to Build a CDS View. It Got the Delta Annotations Right.
Picture the ecommerce team at a mid-sized retailer. They have just shipped a customer-support chatbot — the LLM-driven kind that fields the "where's my order?" and "can you change the delivery address?" tickets that used to eat a third of the call-centre's day. The bot is good. It is also wrong about fifteen percent of the time, because the order data it reasons about is six hours stale: a nightly job dumps VBAK to a Parquet file, the bot reads the file, and customers find out the bot does not know about anything they ordered after lunch.
The fix is obvious — feed the chatbot from a near-real-time stream. The cleanest compliant path is ODP-via-OData: SAP's Gateway exposes the CDS view as an OData v2 entity set, the consumer follows the !deltatoken=… link, the bot's cache lands inside a five-minute freshness window. (More on why OData and not RFC in a moment — that part is no longer a stylistic choice.) The blocker is the CDS view that the ODP framework reads. If you have ever built one by hand, you know the actual coding is the fast part — five lines of select from. The slow part is the annotation block on top, the half-page of @Analytics.dataExtraction.delta.byElement.name, @AccessControl.authorizationCheck, @VDM.viewType, and the @OData.publish switch that surfaces the view through Gateway. Get one of them wrong and the delta isn't a delta. It's a full snapshot every fifteen minutes, and your ODP queue is on fire by lunchtime.
This is the part of SAP development that an AI pair programmer is very good at, if you give it a real CLI to drive. Last weekend I built the chatbot's feed end-to-end with Claude Code and uvx erpl-adt. No MCP server, no daemon — just Claude Code running shell commands the way a developer would. Eleven minutes from request to active, ODP-ready view. This post is what happened, command by command.
The setup
Two commands. The first proves the binary works without installing anything; uvx fetches the wheel, runs it, and forgets it:
uvx erpl-adt --help
The second saves a connection profile so I don't have to repeat --host and --user on every call. erpl-adt login writes a ~/.adt.creds file with 0600 permissions; every subsequent invocation picks it up as a fallback:
uvx erpl-adt login \
--host sap-dev.example.com \
--port 44300 \
--https \
--user CLAUDE_DEV \
--client 100
# Password: ********
# Saved credentials to /home/me/.adt.creds (mode 0600)
That is the entire bootstrap. From here on, every uvx erpl-adt <command> reaches the dev system without further configuration. Claude Code sees erpl-adt as a normal command-line program — same authorization scope as the CLAUDE_DEV user, no special hooks, no agent runtime.
The request
I opened Claude Code in the terminal and typed:
The support chatbot needs a fresh order feed. Build me a CDS view on
top of VBAK with ODP delta enabled, published over OData (not RFC —
SAP Note 3255746). The bot's ingest worker will subscribe via the
OData entity set and follow delta tokens every couple of minutes.
Source system is SAP S/4HANA on-prem. Name the view
Z_I_SalesOrder_Delta, put it in package ZCHATBOT_FEEDS. Use
UTIMESTAMP as the change marker. Build it. Use `uvx erpl-adt` for
SAP calls.
SAP Note 3255746 ("Use of ODP API for non-SAP applications", 2023) restricts the legacy RODPS_REPL_* RFC interface to SAP-internal use. Third-party consumers — including custom chatbots, BI tools, and replication products — must use the ODP-via-OData path through SAP Gateway. The view we are about to build needs an @OData.publish: true annotation so Gateway exposes it as an EntityOf<View> entity set with delta-link support. Skipping this and pointing the chatbot at the old RFC endpoint is a terms-and-conditions violation, not a technical preference.
That is the entire prompt. What follows is the trace of what Claude Code actually did, with the shell commands it ran shown verbatim.
Step 1 — Look first
Claude's first move was not to write code. It was two search calls. Once for sibling views the package might already have, once for VBAK-related views anywhere on the system to crib annotation style from.
uvx erpl-adt search "Z_I_Sales*" --max 20
uvx erpl-adt search "*VBAK*" --max 20
The first search came back empty. The second turned up three existing CDS sources from an earlier extraction project. Claude grabbed one of them to confirm the annotation block the team already considered "standard":
uvx erpl-adt ddic cds Z_I_BILLING_DOC_DELTA
It scanned the header annotations, then paused and asked a question that I had not expected:
The sibling view uses
@AccessControl.authorizationCheck: #NOT_REQUIRED. That is fine when the consumer is internal BW with its own trusted technical user — but the consumer here is a customer-facing chatbot reachable from the open web. Without a DCL role, every row VBAK contains will land in the chatbot's cache, regardless of which customer is asking. I would rather use#CHECKand ship a DCL role that scopes byVKORG. Two extra files. Worth it?
Yes. That was the right question. Claude did not just copy house style — it noticed that the consumer of the data had changed and the auth posture should change with it. The delta annotations (@Analytics.dataExtraction.delta.byElement.name) it took unchanged from the sibling.
Step 2 — Confirm the underlying table
You cannot annotate a delta-by-element on a field that does not exist. Claude read VBAK directly to verify the timestamp column is there and to pick column names that round-trip cleanly:
uvx erpl-adt ddic table VBAK
Output (abridged):
Table: VBAK — Sales Document: Header Data
Field Type Key Description
──────────── ───── ─── ─────────────────────────────
MANDT CLNT * Client
VBELN CHAR * Sales Document
ERDAT DATS Date record created
ERZET TIMS Time of entry
ERNAM CHAR Name of person who created
AUDAT DATS Document date
VKORG CHAR Sales Organization
KUNNR CHAR Sold-to party
NETWR CURR Net value of the sales order
WAERK CUKY SD document currency
UTIMESTAMP DEC Last changed UTC timestamp
UTIMESTAMP exists, decimal-encoded the way SAP likes it for change-data feeds. Good enough — proceed.
Step 3 — The proposed DDL
Now Claude wrote source. It dropped the file straight into the working directory as z_i_salesorder_delta.ddls. I want to show this verbatim because the part that matters is the annotation block, and the part that wins is what Claude did not forget:
@AbapCatalog.sqlViewName: 'ZIVBAKDELTA'
@AbapCatalog.compiler.compareFilter: true
@AbapCatalog.preserveKey: true
@AccessControl.authorizationCheck: #CHECK
@EndUserText.label: 'Sales order headers - chatbot delta feed'
@Analytics.dataCategory: #FACT
@Analytics.dataExtraction.enabled: true
@Analytics.dataExtraction.delta.byElement.name: 'LastChangedAt'
@Analytics.dataExtraction.delta.byElement.maxDelayInSeconds: 1800
@Analytics.dataExtraction.delta.byElement.detectDeletedRecords: false
@Analytics.dataExtraction.delta.byElement.ignoreDeletionsAfterDays: 30
// SAP Note 3255746 — expose to non-SAP consumers via OData, not RFC.
@OData.publish: true
@VDM.viewType: #BASIC
@ObjectModel.dataCategory: #TRANSACTIONAL_DATA
@ObjectModel.usageType.serviceQuality: #C
@ObjectModel.usageType.sizeCategory: #L
@ObjectModel.usageType.dataClass: #TRANSACTIONAL
define view Z_I_SalesOrder_Delta as select from vbak {
key vbeln as SalesOrder,
erdat as CreatedOn,
erzet as CreatedAtTime,
ernam as CreatedBy,
audat as DocumentDate,
vkorg as SalesOrganization,
kunnr as SoldToParty,
netwr as NetAmount,
waerk as Currency,
@Semantics.systemDateTime.lastChangedAt: true
utimestamp as LastChangedAt
}
A few things to call out, because they are the things humans get wrong:
@AccessControl.authorizationCheck: #CHECKis the switch that makes DCL roles take effect. Without it the role artifact compiles but does nothing — every caller sees every row. We will write the matching DCL role in Step 3b.@Analytics.dataExtraction.enabled: trueis the switch that makes the view a valid ODP source. Forget it and the Gateway service comes up but exposes the view as a plain entity set with no delta link — the chatbot's ingest worker pulls a full snapshot every two minutes.@OData.publish: trueis what triggers the Gateway auto-registration on activation. Without it the view exists, ODP recognises it, but there is no HTTP surface — and the OData path mandated by Note 3255746 is exactly the HTTP surface we need. The activator emits the service binding at/sap/opu/odata/sap/Z_I_SALESORDER_DELTA_CDS/and the ODP-OData wrapper atEntityOfZ_I_SalesOrder_Delta.byElement.namereferences the alias (LastChangedAt), not the source column (utimestamp). The compiler does not check this for you in a useful way; the extractor just silently does full snapshots. Claude got it right because it read the alias it had just written, not the field name from the table.@Semantics.systemDateTime.lastChangedAt: trueis what the ODP runtime watches for. Without it, the delta-by-element annotation is structurally valid and semantically broken.maxDelayInSeconds: 1800is the safety window — anything written more than 30 minutes ago is considered "settled" and eligible to leave the delta queue. Claude picked the value the sibling view used.
None of this is hard once you know it. All of it is the kind of thing that takes a junior developer two sprints and a production incident to learn. Claude learned it by reading a sibling.
Step 3b — The DCL role
Because we asked for #CHECK, Claude generated the matching role artifact as z_i_salesorder_delta_role.dcls:
@MappingRole: true
@EndUserText.label: 'Chatbot ingest role - VBAK by VKORG'
define role Z_I_SalesOrder_Delta_DCL {
grant select on Z_I_SalesOrder_Delta
where (SalesOrganization) = aspect pfcg_auth
(V_VBAK_VKO, VKORG, ACTVT = '03');
}
This wires the view to the PFCG auth object V_VBAK_VKO (the standard "Sales Document: Authorization for Sales Organizations" object that any S/4 install already has). The chatbot's technical user gets a PFCG role granting V_VBAK_VKO for exactly the sales organisations the bot is allowed to serve — say 1000 and 2000, not the procurement subsidiary's 9000. The OData reads are now filtered server-side. Compromise the chatbot's ingest worker and the blast radius is two sales orgs, not the whole VBAK.
Now the awkward part. erpl-adt object create currently supports CLAS / PROG / INTF / FUGR / DEVC / DDLS / TABL / DTEL / MSAG — DCLS is not on the list. Claude noticed and said so:
I can write the role source, but I cannot create the DCL stub via
erpl-adt object create. Easiest path: create the emptyZ_I_SalesOrder_Delta_DCLartifact in Eclipse or the ABAP Development Tools Fiori IDE (right-click package → New → Access Control → Role, three clicks). Once the stub exists I will write and activate it viaerpl-adt source write --activate.
I did the three clicks. The empty stub appeared in the package. Claude resumed:
uvx erpl-adt source write \
/sap/bc/adt/acm/dcl/sources/z_i_salesorder_delta_dcl/source/main \
--file z_i_salesorder_delta_role.dcls \
--activate
Activated. Now to the CDS view itself.
Step 4 — Create, write, activate
Three commands. Claude chained them without asking:
# 1. Create the empty DDL source shell in the package.
uvx erpl-adt object create \
--type=DDLS/DF \
--name=Z_I_SalesOrder_Delta \
--package=ZCHATBOT_FEEDS \
--description="Sales order headers - ODP delta feed for support chatbot"
# Created: /sap/bc/adt/ddic/ddl/sources/z_i_salesorder_delta
# 2. Write the DDL source, lock + write + unlock + activate in one shot.
uvx erpl-adt source write Z_I_SalesOrder_Delta \
--file z_i_salesorder_delta.ddls \
--activate
# Source updated: /sap/bc/adt/ddic/ddl/sources/z_i_salesorder_delta/source/main
# Lock acquired, source written, lock released.
# Activated: /sap/bc/adt/ddic/ddl/sources/z_i_salesorder_delta
The --activate flag is the thing that takes a sweaty afternoon and turns it into one command. The CLI handles the CSRF round-trip, the stateful session that carries the lock cookie, the LOCK → WRITE → UNLOCK choreography, and the asynchronous 202 Accepted activation poll. Claude does not have to know that the activation returns a Location header you have to poll. It just runs one command and reads the result.
Bonus point for uvx: each invocation runs in its own ephemeral environment. No pip install left behind, no version drift, no virtualenv to clean up. The first call pulls the wheel and caches it; subsequent calls in the same session are essentially as fast as running a local binary.
Step 5 — Don't trust, verify
Claude's last move was the one I respect it most for. It did not declare victory. It ran the static checker and re-read the source.
uvx erpl-adt check Z_I_SalesOrder_Delta --variant DEFAULT
Output:
ATC Results - Z_I_SalesOrder_Delta (variant: DEFAULT)
0 errors, 1 warning
Priority Object Message
──────── ────────────────────── ──────────────────────────────────────────
Warning Z_I_SalesOrder_Delta Naming convention: use noun phrase in
@EndUserText.label
One pedantic warning about the label phrasing, zero errors. Critically, no warnings about the delta annotations — meaning the extractor framework recognized them as well-formed and consistent.
Then a sanity read-back:
uvx erpl-adt ddic cds Z_I_SalesOrder_Delta
The source came back identical to what was written. Active version present, inactive version gone, ready for an ODP subscriber.
Why this worked
Two things made the difference, and neither was the model's raw cleverness:
Claude had a real CLI, not a chat surface. Every uvx erpl-adt invocation returned text Claude could parse and reason about: created URI, lock state, activation outcome, ATC verdict. When something was ambiguous — was UTIMESTAMP really there? did the sibling view use byElement or changeDataCapture.mapping? — Claude could go look, not guess. The CLI's --json flag is there for the same reason; Claude reached for it twice when it wanted structured output.
The toolset was small and orthogonal. search, ddic table, ddic cds, object create, source write, check. Each does one thing. Compose them and you can do almost anything an Eclipse user can do. Claude did not have to choose between fifteen overlapping "smart" commands.
The same loop works for the next request — "now build me a CDS cube on top of this view" — and for the one after that. Claude is not memorizing a workflow. It is composing primitives.
Try it
If you have a SAP dev system and a Claude Code install, you can reproduce this in about twenty minutes:
# 1. Confirm uvx can launch erpl-adt (no install needed).
uvx erpl-adt --help
# 2. Save your connection profile.
uvx erpl-adt login --host sap-dev.example.com --port 44300 --https \
--user YOUR_USER --client 100
# 3. Smoke-test the connection.
uvx erpl-adt search "Z*" --max 5
# 4. Open Claude Code in the same shell and ask for the view.
You will spend most of those twenty minutes on the SAP side — picking a package, deciding which timestamp column carries your change marker, agreeing on a naming convention. Claude handles the rest.
Once the view is active, Gateway is publishing the OData service automatically (that is what @OData.publish: true is for). The chatbot's ingest worker reaches it from DuckDB via the ERPL-Web odata_read function — HTTP, not RFC, fully inside the lane SAP Note 3255746 carved out for third parties. Authentication is OAuth2 client_credentials against an OAuth client registered in S/4HANA transaction SOAUTH2. The technical user behind the client has exactly one PFCG role — the V_VBAK_VKO-scoped role our DCL artifact reads — so server-side row filtering kicks in automatically:
-- One-time: register the OAuth2 secret.
-- token_url, client_id, client_secret come from SOAUTH2 / your basis team.
CREATE SECRET sap_chatbot (
TYPE oauth2,
PROVIDER oauth2,
TOKEN_URL 'https://s4-prod.example.com/sap/bc/sec/oauth2/token',
CLIENT_ID 'CHATBOT_INGEST',
CLIENT_SECRET 'redacted',
SCOPE 'ZODP_CHATBOT_0001'
);
-- One-time: bootstrap. The response carries a delta link whose
-- token we will persist for the next run.
CREATE TABLE chatbot_cache.orders AS
SELECT *
FROM odata_read(
'https://s4-prod.example.com/sap/opu/odata/sap/' ||
'Z_I_SALESORDER_DELTA_CDS/EntityOfZ_I_SalesOrder_Delta?$format=json',
secret := 'sap_chatbot'
);
-- Every two minutes from the ingest worker:
INSERT INTO chatbot_cache.orders
SELECT *
FROM odata_read(
'https://s4-prod.example.com/sap/opu/odata/sap/' ||
'Z_I_SALESORDER_DELTA_CDS/EntityOfZ_I_SalesOrder_Delta' ||
'!deltatoken=''' || (SELECT delta_token FROM chatbot_cache.cursor) || '''',
secret := 'sap_chatbot'
);
First call: full snapshot of the slice the DCL role permits, cursor opens server-side, the response carries a delta link whose token the worker persists. Every call after that uses !deltatoken=… and returns only the rows that changed since that token. Token refresh on the OAuth side is automatic — ERPL-Web handles the access-token lifecycle from the secret.
Three things to notice about this auth posture:
- Server-side row filtering, courtesy of
@AccessControl.authorizationCheck: #CHECK+ the DCL role. IfCHATBOT_INGESTever leaks, the worst an attacker can pull is the two sales organisations the PFCG role permits — not the whole VBAK. - No password in the DuckDB secret. The OAuth client_secret is a separate credential rotatable from
SOAUTH2, and the access tokens themselves never touch disk. - HTTP, not RFC. Every byte goes over the Gateway service. We are inside the path SAP Note 3255746 designated for third-party consumers, and audits will see it the same way.
The chatbot reads chatbot_cache.orders whenever a customer asks where their order is. The answer is at most two minutes behind SAP, and it is their order — not some other customer's order the bot guessed at. The fifteen-percent hallucination rate that started the project drops to numbers nobody complains about, because the bot is now reasoning about a world that actually exists and that it is allowed to see.
Footnote — if you would rather run it as an MCP server
The same erpl-adt binary also speaks the Model Context Protocol. If your workflow is "leave the agent connected for a whole session" instead of "let Claude Code shell out per call", run erpl-adt mcp ... instead of registering a CLI — the introducing-erpl-adt post walks through that mode. For one-shot scripted tasks like this one, the CLI is the simpler surface.
erpl-adt is part of the ERPL family of SAP integration tools by DataZoo. The CLI is open source — github.com/DataZooDE/erpl-adt.
