Wiring an LLM agent to S/4HANA OData without losing your mind
A working pattern for letting a language model query — and eventually mutate — the suite of business objects exposed by S/4HANA, using OData v4, scoped tools, and a strict allowlist.
The first time you point an LLM at an S/4HANA system, the temptation is to give it a single runSQL-style tool and hope for the best. Don't. The OData layer exists for a reason: it encodes what the business is willing to expose, what filters are safe, and what side effects a caller is allowed to trigger. The agent should respect that contract — not bypass it.
This dispatch lays out a pattern we've used in production: a thin OData client, a tightly scoped set of tools, and a refusal to ever pass a free-form URL into the model's hands.
The shape of the problem
S/4HANA exposes thousands of OData services — finance, logistics, master data, HR, you name it. A useful agent rarely needs more than a handful of entity sets at a time. The job is to:
- Pick the smallest set of services that solves the user's task.
- Wrap each one as a typed tool with a narrow input schema.
- Mediate every call through a server-side allowlist that the model cannot see, let alone edit.
The OData client
Start with a boring fetch wrapper. No SDK acrobatics, no metadata-driven magic. We want something we can read in an afternoon and audit in an hour.
// lib/s4/odata.ts
type ODataQuery = {
$filter?: string;
$select?: string;
$expand?: string;
$top?: number;
$skip?: number;
};
export async function odataGet<T>(
service: string,
entitySet: string,
query: ODataQuery = {}
): Promise<{ value: T[] }> {
const base = process.env.S4_BASE_URL!;
const token = await getBtpToken(); // OAuth2 client-credentials via XSUAA
const params = new URLSearchParams(
Object.entries(query).filter(([, v]) => v !== undefined) as [string, string][]
);
const url = `${base}/sap/opu/odata/sap/${service}/${entitySet}?${params}`;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
"sap-client": process.env.S4_CLIENT ?? "100",
},
});
if (!res.ok) throw new ODataError(res.status, await res.text());
return res.json();
}Two things matter here. First, the service and entitySet arguments are strings we control — they are never read off a model output directly. Second, the OAuth token comes from BTP's XSUAA, not from a static credential checked into a repo.
The tool surface
Now wrap each entity set the agent is allowed to touch as its own tool. Resist the urge to write one generic queryOData tool. Generic tools produce generic prompts, generic prompts produce generic mistakes.
// agent/tools/business-partner.ts
import { tool } from "@anthropic-ai/sdk/helpers";
import { z } from "zod";
import { odataGet } from "@/lib/s4/odata";
export const findBusinessPartner = tool({
name: "find_business_partner",
description:
"Look up a business partner by ID, name fragment, or city. Returns up to 25 matches.",
input_schema: z.object({
id: z.string().optional().describe("Exact BP number, e.g. '0010001234'"),
nameContains: z.string().optional(),
city: z.string().optional(),
}),
async run({ id, nameContains, city }) {
const filters: string[] = [];
if (id) filters.push(`BusinessPartner eq '${id.replace(/'/g, "''")}'`);
if (nameContains)
filters.push(`contains(BusinessPartnerName, '${nameContains.replace(/'/g, "''")}')`);
if (city) filters.push(`to_BusinessPartnerAddress/CityName eq '${city.replace(/'/g, "''")}'`);
return odataGet("API_BUSINESS_PARTNER", "A_BusinessPartner", {
$filter: filters.join(" and ") || undefined,
$select: "BusinessPartner,BusinessPartnerName,BusinessPartnerCategory",
$top: 25,
});
},
});The schema is the contract. The model can't pass $filter directly — it can only pass the three fields you've decided are safe. Quote-escaping happens in the wrapper, not in the prompt.
Wiring it up to Claude
With the tools defined, the agent loop is small. Here's the loop pared down to its essentials:
// agent/loop.ts
import Anthropic from "@anthropic-ai/sdk";
import { findBusinessPartner } from "./tools/business-partner";
import { lookupSalesOrder } from "./tools/sales-order";
const tools = [findBusinessPartner, lookupSalesOrder];
const client = new Anthropic();
export async function runAgent(userMessage: string) {
const messages: Anthropic.MessageParam[] = [
{ role: "user", content: userMessage },
];
while (true) {
const res = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 2048,
system:
"You are an SAP operations assistant. Only act through the tools provided. " +
"If a request requires a tool you don't have, say so plainly.",
tools: tools.map((t) => t.spec),
messages,
});
if (res.stop_reason !== "tool_use") return res;
messages.push({ role: "assistant", content: res.content });
const toolUses = res.content.filter((b) => b.type === "tool_use");
const results = await Promise.all(
toolUses.map(async (use) => {
const tool = tools.find((t) => t.name === use.name);
if (!tool) return { id: use.id, error: "unknown_tool" };
try {
const result = await tool.run(use.input);
return { id: use.id, result };
} catch (err) {
return { id: use.id, error: String(err) };
}
})
);
messages.push({
role: "user",
content: results.map((r) => ({
type: "tool_result",
tool_use_id: r.id,
content: JSON.stringify(r.result ?? { error: r.error }),
is_error: !!r.error,
})),
});
}
}That's the entire pattern. The model proposes; the wrapper disposes. Errors come back as tool results, not as exceptions — the model can recover from a bad lookup the same way a junior consultant would: try a different filter.
What we're not doing yet
You'll notice every tool above is read-only. Mutating S/4HANA objects through an agent — creating sales orders, releasing payments, posting journal entries — is a different conversation, and one we'll have in a future dispatch. The short version: a confirmation step, a human-in-the-loop, and a change_id that survives across the round-trip.
The interesting question is never "can the model do it?" It's "what does the system do when the model is wrong?"
For read-only flows, the answer is: not much, and that's the point. Build the boring half first, instrument it well, and only then start handing over the keys.
Coming up next
A walkthrough of the same pattern against CAP services on BTP, plus how to layer a Joule-style conversational shell on top without rewriting your tools. If you'd like that one in your inbox, the subscribe link will live at the bottom of the next dispatch.