logo

Are you need IT Support Engineer? Free Consultant

Building an LLM Agent in CAP Java with Spring AI

  • By sujay
  • 08/06/2026
  • 21 Views

Agentic systems are all the rage these days. Previously I showed how to implement an LLM agent in CAP using the CAP Node.js runtime. Let's look at the CAP Java side.

Agenda:

Overview

First, what is an agent? There are many definitions out there, but here are a few that I like:

“Agents combine language models with tools to create systems that can reason about tasks, decide which tools to use, and iteratively work towards solutions.” – langchain
“Agents are systems that independently accomplish tasks on your behalf.” – openai
“An LLM in a loop with an objective” – Simon Willison
“An LLM, some tools and a loop meet in a bar” – me

That last one makes for some good jokes.

In essence, the agent orchestrates the interaction between the model, the tools, and the user.

The tool output is fed back into the model as context for the next step, allowing the agent to iteratively work towards a solution. The loop is continued until the model outputs a final answer or meets a predefined stopping condition like a maximum iteration count. This is what allows agents to handle complex tasks that require multiple steps and interactions with external systems.

TLDR

If you just want a quick example, here you go. If you want to understand how to integrate agents into a CAP Java application step by step, read on.

// .../agent/TravelAgent.java
@Configuration
public class TravelAgent {
  @Autowired TravelTools tools;

  @Bean
  ChatClient agent() {
    OrchestrationModuleConfig config = new OrchestrationModuleConfig().withLlmConfig(OrchestrationAiModel.MISTRAL_SMALL);
    OrchestrationChatOptions opts = new OrchestrationChatOptions(config);
    ChatModel model = new OrchestrationChatModel();
    return ChatClient.builder(model)
      .defaultOptions(opts)
      .defaultSystem("Today is " + LocalDate.now())
      .defaultTools(tools)
      .build();
  }
}
// .../agent/TravelTools.java
@Component
public class TravelTools {
  @Autowired TravelService travelService;
  @Autowired CdsRuntime runtime;

  @Tool
  List searchTrips(
    @ToolParam(description = "begin date") LocalDate beginDate,
    @ToolParam(description = "end date") LocalDate endDate
  ) {
    var query = Select.from(Trips_.class)
      .where(t -> t.beginDate().ge(beginDate) .and( t.endDate().le(endDate) ));
    var result = travelService.run(query);
    return result.list();
  }
}
// .../handler/TravelHandler.java
@Component @ServiceName(TravelService_.CDS_NAME)
public class TravelHandler implements EventHandler {
  @Autowired ChatClient agent;

  @On
  String invokeAgent(InvokeAgentContext context) {
    return agent.prompt(context.getInput()).call().content();
  }
}

On libraries and workflows

In the Java world, there are multiple libraries to choose from, for example langchain4j and Spring AI. Since both CAP Java and the SAP AI SDK already integrate well with Spring and Spring AI, we use Spring AI in this post.

Searching for building agents with Spring AI, we most prominently find a blog post on building effective agents from January 2025. It is based on a similarly named post by Anthropic, which describes the important distinction between workflows and agents. Workflows are predefined sequences of steps, while agents are running in a feedback loop with their environment until they reach a stopping point.

The Spring AI documentation mainly focuses on manual workflows, which can be misleading when it comes to building an agent. The Spring AI ChatClient already includes the core agentic loop. This is also highlighted further in newer blog posts about Spring AI, for example with exposing generic agent skills as a tool and a2a integration.

Initialize the CAP app

To get started, we will initialize a new CAP project using the cds init command. This will create a new directory with the necessary files and folders for our CAP application. For this tutorial, we will use the Java runtime. If you are interested in CAP Node.js instead, check this blog post.

cds init cap-agent --java
code cap-agent  # open in vscode

Connecting to SAP AI Core with the SAP AI SDK

We will use the SAP AI SDK to connect our CAP application to the SAP AI Core service. This will allow us to call a variety of language models from our CAP application.

First, make sure that you have an SAP AI Core service instance. The setup is described here: https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/initial-setup

If using the Cloud Foundry cli, you can create a service instance like this:

cf create-service aicore extended agent-ai

Now we can use cds bind to create a service key and save a reference to it in .cdsrc-private.json. This will come in handy later when we want to try it out.

cds bind -2 agent-ai

Next add the SAP AI SDK and Spring AI dependencies:



  ...
  
    org.springframework.ai
    spring-ai-commons
  
  
    org.springframework.ai
    spring-ai-model
  
  
    org.springframework.ai
    spring-ai-client-chat
  
  
    com.sap.ai.sdk
    orchestration
    ${ai-sdk.version}
  


  ...
  
  1.1.7
  1.19.0


  
    ...
    
      org.springframework.ai
      spring-ai-bom
      ${spring-ai.version}
      pom
      import
    
  

We can test out our connection by creating a simple test:

// src/test/java/customer/cap_agent/AgentTest.java
package customer.cap_agent;

import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;

import com.sap.ai.sdk.orchestration.OrchestrationAiModel;
import com.sap.ai.sdk.orchestration.OrchestrationModuleConfig;
import com.sap.ai.sdk.orchestration.spring.OrchestrationChatModel;
import com.sap.ai.sdk.orchestration.spring.OrchestrationChatOptions;

public class AgentTest {
  private final static Logger log = LoggerFactory.getLogger(AgentTest.class);

  private final OrchestrationChatOptions opts = new OrchestrationChatOptions(
    new OrchestrationModuleConfig()
      .withLlmConfig(OrchestrationAiModel.MISTRAL_SMALL));

  @ Test
  void testSdkPrompt() {
    ChatModel model = new OrchestrationChatModel();
    Prompt prompt = new Prompt("What is the capital of France?", opts);
    ChatResponse response = model.call(prompt);
    String content = response.getResult().getOutput().getText();
    log.info(content);
    assertTrue(content.contains("Paris"));
  }
}

Now let's utilize our earlier cds bind reference to run the test with the correct environment variables:

cds bind --exec -- mvn test -Dtest=customer.cap_agent.AgentTest#testSdkPrompt

The cds bind --exec command will set the environment variables from the service key we created earlier, so that the SAP AI SDK can authenticate to the SAP AI Core service.

The nice thing about cds bind --exec is that it retrieves the credentials ad-hoc, so they are not saved on disk. However, we cannot dynamically inject environment variables into the Java test runner in VSCode. So if we want to run the tests from VSCode, we need to save the environment variables in a file first. We can do this with the following command:

cds bind --exec -- node -e "require('fs').writeFileSync('.hybrid.env', 'VCAP_SERVICES=' + process.env.VCAP_SERVICES, 'utf8')"

Make sure that the file is in the .gitignore:

# .gitignore
*.env

Then we configure the test runner to use this .hybrid.env file:

// .vscode/settings.json
"java.test.config": [
   {
     "name": "cap-bound-tests",
     "workingDirectory": "${workspaceFolder}",
     "envFile": "${workspaceFolder}/.hybrid.env"
   }
 ]

Spring AI: Calling an agent

If you're familiar with agents, you can skip this section and jump directly to calling an agent from CAP. But if you're new to agents, this section is for you.

As mentioned earlier, agents combine language models with tools. Leaving out the tools, an agent behaves similarly to a direct model call. Let's add a simple test to verify this:

// src/test/java/customer/cap_agent/AgentTest.java
  @ Test
  void testPrompt() {
    ChatModel model = new OrchestrationChatModel();
    ChatClient agent = ChatClient.builder(model).defaultOptions(opts).build();
    String content = agent.prompt("What is the capital of France?").call().content();
    log.info(content);
    assertTrue(content.contains("Paris"));
  }
cds bind --exec -- mvn test -Dtest=customer.cap_agent.AgentTest#testPrompt

Now this is a very simple agent that doesn't do much more than the model itself, but it sets the stage for adding tools and iterating in a loop based on tool output, which is where the real power of agents comes in.

Spring AI: Calling a tool

Let's say we have some data about available trips…

record Trip (LocalDate beginDate, LocalDate endDate, String description) {};

List availableTrips = List.of(
  new Trip(
    LocalDate.parse("2026-06-13"),
    LocalDate.parse("2026-06-14"),
    "Weekend trip to Heidelberg featuring castle sightseeing, hiking and excellent cuisine."),
  new Trip(
    LocalDate.parse("2026-06-13"),
    LocalDate.parse("2026-06-14"),
    "Daytrip to Europa-Park with exciting rollercoasters.")
);

We can now define a method to search for trips based on a time frame.

public List searchTrips(LocalDate beginDate, LocalDate endDate) {
  return availableTrips.stream().filter(
    t ->  !t.beginDate.isBefore(beginDate) &&
          !t.endDate.isAfter(endDate)).toList();
}

Spring AI allows us to expose this method as a @Tool that the agent can call.

@Tool(description = "Search trips by time frame")
List searchTrips(
  @ToolParam(description = "Begin date") LocalDate beginDate,
  @ToolParam(description = "End date") LocalDate endDate
) {
  log.info("[tool call]: searchTrips " + beginDate.toString() + " - " + endDate.toString());
  return availableTrips.stream().filter(
    t ->  !t.beginDate.isBefore(beginDate) &&
          !t.endDate.isAfter(endDate)).toList();
}

Since Java is already a typed language, the method signature can be used by Spring AI to automatically generate a JSON schema for the tool. The provided descriptions are also used in the schema, allowing the model to understand when and how to use the tool.

⚠️Important Note: The schema only advises the model and JSON parsing only validates structural correctness. It is still possible for the agent to provide semantically incorrect inputs that pass validation. This can also be maliciously exploited, so the inputs need to be handled as untrusted data. For example, be aware of potential SQL injection if the tool interacts with a database.

Now we can add the tool to our agent and test it out:

// src/test/java/customer/cap_agent/AgentTest.java
  class TravelTools {
    record Trip (LocalDate beginDate, LocalDate endDate, String description) {};

    List availableTrips = List.of(
      new Trip(
        LocalDate.parse("2026-06-13"),
        LocalDate.parse("2026-06-14"),
        "Weekend trip to Heidelberg featuring castle sightseeing, hiking and excellent cuisine."),
      new Trip(
        LocalDate.parse("2026-06-13"),
        LocalDate.parse("2026-06-14"),
        "Daytrip to Europa-Park with exciting rollercoasters.")
    );

    @Tool(description = "Search trips by time frame")
    List searchTrips(
      @ToolParam(description = "Begin date") LocalDate beginDate,
      @ToolParam(description = "End date") LocalDate endDate
    ) {
      log.info("[tool call]: searchTrips " + beginDate.toString() + " - " + endDate.toString());
      return availableTrips.stream().filter(
        t ->  !t.beginDate.isBefore(beginDate) &&
              !t.endDate.isAfter(endDate)).toList();
    }
  }

  @ Test
  void testTool() {
    ChatModel model = new OrchestrationChatModel();
    ChatClient agent = ChatClient.builder(model)
      .defaultOptions(opts)
      .defaultSystem("Today is 2026-06-10") // hardcoded date to make the test deterministic
      .defaultTools(new TravelTools())
      .build();
    String content = agent.prompt("Is there a trip next weekend?").call().content();
    log.info(content);
    assertTrue(content.contains("Heidelberg"));
  }
cds bind --exec -- mvn test -Dtest=customer.cap_agent.AgentTest#testTool

The agent should recognize that the user is asking for a trip recommendation, decide to use the searchTrips tool and use the answer for the final response.

Notice that we've also adjusted the system prompt to include the “current” date:

      .defaultSystem("Today is 2026-06-10") // hardcoded date to make the test deterministic

If we do not provide the current date, the model typically falls back to its training data… in my case, July 2024. Not quite ideal for planning a weekend trip in 2026. Feel free to experiment with different dates and see how it affects the agent's behavior. You can also try asking for trips on specific dates or with different criteria to see how the agent utilizes the tool.

CAP: Create a data model and a service

Now that we know how to build an agent, let's see how we can integrate it into a CAP application with a real data model and service.

First, let's create a simple data model for storing trip information. We can define this in the db/schema.cds file:

// db/schema.cds
namespace example.travel;
using { User } from '@sap/cds/common';


entity Trips {
  key ID       : Integer @readonly;
  description  : String(2048);
  beginDate    : Date default $now;
  endDate      : Date default $now;
  bookingFee   : Decimal(9,4) default 0;
  currency     : String(3) default 'EUR';
  agency       : Association to Agencies;
  bookings     : Association to many Bookings on bookings.trip = $self;
}

entity Agencies {
    key ID : Integer @readonly;
    name   : String(256);
}


entity Bookings {
  key trip        : Association to Trips;
  key user        : User @readonly @(cds.on.insert: $user);
      bookingDate : Date default $now;
}

Then define a customer facing service in srv/travel-service.cds:

// srv/travel-service.cds
using { example.travel } from '../db/schema';

@odata
service TravelService {
    @readonly entity Trips as projection on travel.Trips
    actions {
        action book() returns String;
    };

    @readonly entity Agencies as projection on travel.Agencies;
    @readonly entity Bookings as projection on travel.Bookings where $user = user;
}

This service exposes the Trips and Agencies entities and also defines a custom action book for booking a trip. We will implement the logic for this action later.

Next, we need some sample data. Always use cds add data to add syntactically correct sample data. Let's add some trips and agencies:

cds add data -n 5 --filter Agencies
cds add data -n 10 --filter Trips

You can use an LLM to generate more meaningful test data while adhering to the existing schema. For example:

db/data/example.travel-Trips.csv

ID,description,beginDate,endDate,bookingFee,currency,agency_ID
2001,"7-Night Maldives Overwater Bungalow Retreat — private chef dinners, unlimited snorkeling excursions, sunset dolphin cruise, and couples spa all included",2026-06-14,2026-06-21,4299.0000,EUR,1001
2002,"14-Night Grand Mediterranean Yacht Odyssey — sail Barcelona to Athens with stops in Ibiza, Amalfi, and Santorini; premium open bar, fine dining, and water sports throughout",2026-07-05,2026-07-19,6850.0000,EUR,1002
2003,"10-Night Bali Spiritual & Adventure Escape — morning yoga, volcano trekking, rice terrace cycling, nightly Kecak fire dance performances, and full board at a jungle resort",2026-08-01,2026-08-11,3199.0000,USD,1003
2004,"5-Night Dubai Ultra-Luxury City Break — penthouse at Burj Al Arab, desert falcon safari, helicopter city tour, and Michelin-starred dinners every evening",2026-09-10,2026-09-15,7499.0000,USD,1004
2005,"12-Night African Safari & Zanzibar Beach Combo — Great Migration in the Serengeti, game drives at dusk, private Zanzibar beach resort, spice farm tour, all meals included",2026-10-02,2026-10-14,5975.0000,EUR,1005
2006,"8-Night Norway Northern Lights Adventure — dog sledding, ice hotel overnight stay, snowmobile aurora tour, reindeer farm visit, and traditional Viking feast",2027-01-15,2027-01-23,5250.0000,EUR,1006
2007,"11-Night Thailand Island Hopper — Koh Samui, Koh Phi Phi, and Phuket; speedboat transfers, beach parties, Muay Thai show, and a gourmet Thai cooking class",2026-11-08,2026-11-19,2899.0000,USD,1007
2008,"9-Night Amalfi Coast & Tuscany Gourmet Tour — private villa stays, truffle hunting, wine tasting at five vineyards, pasta-making masterclass, and a private boat day",2026-05-20,2026-05-29,5100.0000,EUR,1008
2009,"6-Night Cancún All-Inclusive Sun & Surf — beachfront resort, unlimited cocktails, catamaran party boat, cenote snorkeling, and a Mayan ruins guided day trip",2026-12-01,2026-12-07,1999.0000,USD,1009
2010,"15-Night New Zealand Adventure Circuit — bungee jumping in Queenstown, Milford Sound cruise, hobbit village tour, Rotorua geothermal spa, and Tongariro Alpine Crossing",2027-03-10,2027-03-25,7200.0000,EUR,1010

db/data/example.travel-Agencies.csv

ID,name
1001,SunEscape Holidays
1002,Wanderlust All-Inclusive
1003,Tropical Getaways Ltd.
1004,Azure Horizon Travel
1005,Paradise Found Agency
1006,Elite Escapes International
1007,Oceanic Dreams Travel
1008,Grand Voyage Co.
1009,Blissful Journeys
1010,The Luxury Travel Bureau

 

Run the application with

mvn cds:watch

You can now navigate to http://localhost:8080/ and inspect the api endpoints and the generated test data.

Calling the agent in CAP with a query tool (read)

First, let's save our agent into its own Spring Bean so that we can inject it into our service implementation:

// src/main/java/customer/cap_agent/agent/TravelAgent.java
package customer.cap_agent.agent;

import java.time.LocalDate;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import com.sap.ai.sdk.orchestration.OrchestrationAiModel;
import com.sap.ai.sdk.orchestration.OrchestrationModuleConfig;
import com.sap.ai.sdk.orchestration.spring.OrchestrationChatModel;
import com.sap.ai.sdk.orchestration.spring.OrchestrationChatOptions;

@Configuration
public class TravelAgent {

  @Autowired
  TravelTools tools;

  @Bean
  ChatClient agent() {
    OrchestrationModuleConfig config = new OrchestrationModuleConfig().withLlmConfig(OrchestrationAiModel.MISTRAL_SMALL);
    OrchestrationChatOptions opts = new OrchestrationChatOptions(config);
    ChatModel model = new OrchestrationChatModel();
    return ChatClient.builder(model)
      .defaultOptions(opts)
      .defaultSystem("Today is " + LocalDate.now())
      .defaultTools(tools)
      .build();
  }
}

To the fun part: integrating the agent into our CAP application. We will create a new tool that allows the agent to query available trips based on a date range, similar to the one we created in the testing section, but this time it will query our actual data model.

// src/main/java/customer/cap_agent/agent/TravelTools.java
package customer.cap_agent.agent;

import java.time.LocalDate;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import com.sap.cds.ql.Select;

import cds.gen.travelservice.TravelService;
import cds.gen.travelservice.Trips;
import cds.gen.travelservice.Trips_;

@Component
public class TravelTools {
  private final Logger log = LoggerFactory.getLogger(TravelTools.class);

  @Autowired
  TravelService travelService;

  @Tool
  List searchTrips(
    @ToolParam(description = "begin date") LocalDate beginDate,
    @ToolParam(description = "end date") LocalDate endDate
  ) {
    log.info("[tool call]: searchTrips " + beginDate + " - " + endDate);
    var query = Select.from(Trips_.class)
      .where(t -> t.beginDate().ge(beginDate) .and( t.endDate().le(endDate) ));
    var result = travelService.run(query);
    return result.list();
  }
}

Inspecting the tool implementation, we use a Java CQL Select with a where clause checking the date range.

var query = Select.from(Trips_.class)
  .where(t -> t.beginDate().ge(beginDate) .and( t.endDate().le(endDate) ));

Note that the beginDate and endDate tool inputs are automatically converted to query parameters:

SELECT ... WHERE T0."BEGINDATE" >= ? and T0."ENDDATE" <= ?

This is normal in CAP Java, and it is important, as the agent might provide malicious inputs that could lead to SQL injection if not handled properly. You can also check the SQL statements yourself by setting the log level for com.sap.cds.persistence.sql to DEBUG:

# srv/src/main/resources/application.yaml
logging.level:
  com.sap.cds.persistence.sql: DEBUG

We can then execute the query using the injected TravelService, which will ensure that all the usual service logic is applied, including user permissions.

var result = travelService.run(query);

Since we call the travel service, we use cds.gen.travelservice.Trips_ and cds.gen.travelservice.Trips as model and accessor interfaces.

Next, let's call the agent from our service implementation. In srv/travel-service.cds, we define the invokeAgent action, allowing us to call the agent over HTTP.

// srv/travel-service.cds
@odata
service TravelService {
    ...
    action invokeAgent(input: String) returns String;
}

If you still have mvn cds:watch running, the cds model should be recompiled automatically. If not, run mvn compile to compile the model and generate the necessary Java classes for the new action.

Then we add the handler implementation which simply calls the agent.

// src/main/java/customer/cap_agent/handler/TravelHandler.java
package customer.cap_agent.handler;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import com.sap.cds.services.handler.EventHandler;
import com.sap.cds.services.handler.annotations.On;
import com.sap.cds.services.handler.annotations.ServiceName;

import cds.gen.travelservice.TravelService_;
import cds.gen.travelservice.InvokeAgentContext;

@Component
@ServiceName(TravelService_.CDS_NAME)
public class TravelHandler implements EventHandler {
  @Autowired
  ChatClient agent;

  @On
  String invokeAgent(InvokeAgentContext context) {
    return agent.prompt(context.getInput()).call().content();
  }
}

Run the application with

cds bind --exec -- mvn cds:watch

This utilizes our earlier cds bind command, so we can call the agent with the correct credentials for SAP AI Core.

To invoke the agent, create a test/http/TravelService.http file using cds add http. Since we chose the @odataprotocol, we can call our action at /odata/v4/TravelService/invokeAgent:

# test/http/TravelService.http
@server=http://localhost:8080
@username=authenticated
@password=

### invokeAgent
# @NAME invokeAgent_POST
POST {{server}}/odata/v4/TravelService/invokeAgent
Content-Type: application/json
Authorization: Basic {{username}}:{{password}}

{
  "input": "What are exciting trips for this summer? I like to go to the beach"
}

Since we added logging to our tool, you should observe the agent calling the searchTrips tool with the appropriate date range for summer:

[tool call]: searchTrips 2026-06-01 - 2026-08-31

The response contains the final answer from the agent, which should include the available trips for this summer.

This shows the agentic ReAct (Reasoning and Acting) loop in action. Conceptually:

System: Today is 2026-06-10
User: What are exciting trips for this summer? I like to go to the beach
Agent: [Reasoning]
Agent: [Act]          [tool call]: searchTrips 2026-06-01 - 2026-08
Agent: [Observe]      [tool response]: [Trip{beginDate=2026-06-14, endDate=2026-06-21, description='7-Night Maldives ... }, ...]
Agent: [Repeat]
Agent: [Reasoning]
Agent: [Finish]       I found an exciting trip for you this summer: ...

The complete loop happens inside the ChatClient. If you want to see the intermediate steps, add a SimpleLoggerAdvisor for logging the model requests and responses.

You can experiment with different queries and see how the agent utilizes the tool to fetch relevant information from our CAP service.

You could also add additional parameters to the tool. For example, you could utilize the description field to search for specific interests like “beach”, “skiing”, or “hiking”. As a simple approach, use a text search. Or use vector embeddings with a cosine similarity search. With CAP, this is quite easy to implement.

Calling the agent in CAP with an action tool (write)

The query tool we implemented is useful to provide the agent with data. However, we can also implement tools that allow an agent to take action. For example, let's implement a tool that allows the agent to book a trip on behalf of the user.

In CAP, a dedicated action allows us to implement the business logic for booking a trip. Earlier, we had already defined the bound action book in our CDS model:

service TravelService {
    @readonly entity Trips as projection on travel.Trips
    actions {
        action book() returns String;
    };
    ...
}

If you still have mvn cds:watch running, the cds model should be recompiled automatically. If not, run mvn compile to compile the model and generate the necessary Java classes for the new action.

Now we implement the corresponding event handler.

// src/main/java/customer/cap_agent/handler/BookingHandler.java
package customer.cap_agent.handler;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import com.sap.cds.ql.Insert;
import com.sap.cds.ql.cqn.CqnAnalyzer;
import com.sap.cds.services.handler.EventHandler;
import com.sap.cds.services.handler.annotations.On;
import com.sap.cds.services.handler.annotations.ServiceName;
import com.sap.cds.services.persistence.PersistenceService;

import cds.gen.example.travel.Bookings;
import cds.gen.example.travel.Bookings_;
import cds.gen.travelservice.TravelService_;
import cds.gen.travelservice.TripsBookContext;

@Component
@ServiceName(TravelService_.CDS_NAME)
public class BookingHandler implements EventHandler {
  @Autowired
  PersistenceService db;

  @On
  String book(TripsBookContext context) {
    CqnAnalyzer analyzer = CqnAnalyzer.create(context.getModel());
    var result = analyzer.analyze(context.getCqn());
    Integer tripId = (Integer) result.targetKeys().get("ID");

    Bookings booking = Bookings.create();
    booking.setTripId(tripId);
    var query = Insert.into(Bookings_.class).entries(List.of(booking));
    db.run(query);
    return "Successfully booked trip " + tripId;
  }
}

As you see, this is a very simple implementation that just inserts a new booking for the given trip. In a real application, you would usually have a multi-step process that checks for availability with the vendor, handles payment and then comes back with a confirmation. But for demonstration purposes, this implementation is sufficient to show the idea and important considerations.

The action itself is registered to the service and available to the user. However, the bookings entity is only exposed to the user as readonly.

@readonly entity Bookings as projection on travel.Bookings where $user = user;

So in the action implementation, we go directly to the database via the PersistenceService utilizing the db level entity interfaces cds.gen.example.travel.Bookings and cds.gen.example.travel.Bookings_ to insert a new booking. This database insert bypasses the service level permission checks while still maintaining the user context.

Recalling our domain model, we automatically insert the user and the booking date:

entity Bookings {
  key trip        : Association to Trips;
  key user        : User @readonly @(cds.on.insert: $user);
      bookingDate : Date default $now;
}

Now that we have implemented the action, we can expose it as a tool for our agent.

// src/main/java/customer/cap_agent/agent/TravelTools.java
  @Tool
  String bookTrip(
    @ToolParam(description = "ID of the trip") Integer tripId
  ) {
    log.info("[tool call]: bookTrip " + tripId);
    Trips_ trip = CQL.entity(Trips_.class).matching(Map.of("ID", tripId));
    return travelService.book(trip);
  }

We receive the trip id as a parameter from the LLM model and then call the action via travelService.book(trip). This ensures that all the service level logic is applied, including permissions. The trip is an entity reference matching the given ID.

Since our agent now has the ability to book a trip, it is also useful to provide a tool for checking existing bookings, so that the agent can verify if a trip has been booked.

// src/main/java/customer/cap_agent/agent/TravelTools.java
...
import cds.gen.travelservice.Bookings;
import cds.gen.travelservice.Bookings_;
...
  @Tool
  List myBookings() {
    log.info("[tool call]: myBookings");
    var query = Select.from(Bookings_.class);
    var result = travelService.run(query);
    return result.list();
  }

This touches on an important aspect of agent design: providing feedback to the agent about its actions. When an agent takes an action, it should be able to observe the outcome of that action. Not only via the direct response of the action, but also by querying the state of the system.

Now that we have the necessary tools for booking a trip, let's see how our agent utilizes them.

# test/http/TravelService.http
...
### invokeAgent
# @NAME invokeAgent_POST
POST {{server}}/odata/v4/TravelService/invokeAgent
Content-Type: application/json
Authorization: Basic {{username}}:{{password}}

{
  "input": "Book a trip for me this summer. I like to go to the beach. Don't ask me, just book it."
}

Checking the tool calls, the agent again first searches for available trips in the summer. It then proceeds to book a matching trip. Great!

[tool call]: searchTrips 2026-06-01 - 2026-08-31
[tool call]: bookTrip 2001

Sometimes it might book multiple trips. This highlights the importance of careful agent design and implementation of guardrails. Since agents can take actions autonomously, it is crucial to ensure that they do not take unintended actions that could have negative consequences.

There are multiple dials you can use to improve the agent's behavior, such as adjusting the system prompt, providing more specific instructions in the tool descriptions. Play around with these parameters and see how it affects the agent's behavior.

But despite all instructions, there is still a chance that the agent takes unintended actions. So make sure to also implement guardrails such as confirmation steps for critical actions, or a manual review process for high-impact decisions.

Transactional Handling

CAP Java automatically creates ChangeSet contexts on each request. Since the tool calls are all happening inside a single request, they share the same ChangeSet context and thus the same transaction.

If you have a long running task with multiple modifying tool calls, you have a long running transaction… which is typically a bad thing. To avoid this, we explicitly open a new ChangeSet context inside the booking tool:

// src/main/java/customer/cap_agent/agent/TravelTools.java
  @Autowired
  CdsRuntime runtime;

  @Tool
  String bookTrip(
    @ToolParam(description = "ID of the trip") Integer tripId
  ) {
    log.info("[tool call]: bookTrip " + tripId);
    Trips_ trip = CQL.entity(Trips_.class).matching(Map.of("ID", tripId));
    return runtime.changeSetContext().run(ctx -> {
      return travelService.book(trip);
    });
  }

Now each booking will be handled in its own transaction, which we can observe with logging level com.sap.cds.persistence.sql-tx: DEBUG:

Opened ChangeSet 98
[tool call]: searchTrips 2026-06-01 - 2026-08-31
Creating new transaction with name [PersistenceService$Default-ChangeSet-98]: PROPAGATION_REQUIRES_NEW,ISOLATION_DEFAULT
...
[tool call]: bookTrip 2036
Opened ChangeSet 99
Suspending current transaction, creating new transaction with name [PersistenceService$Default-ChangeSet-99]
...
Completing ChangeSet 99
Initiating transaction commit
...
Resuming suspended transaction after completion of inner transaction
Closed ChangeSet 99
[tool call]: bookTrip 2052
Opened ChangeSet 100
Suspending current transaction, creating new transaction with name [PersistenceService$Default-ChangeSet-100]
...
Completing ChangeSet 100
Initiating transaction commit
...
Resuming suspended transaction after completion of inner transaction
Closed ChangeSet 100
Completing ChangeSet 98
Initiating transaction commit
...
Closed ChangeSet 98

What's next?

We have now seen how to implement an agentic system in CAP Java using the SAP AI SDK and Spring AI by implementing a simple travel agent that can search for trips and book them on behalf of the user.

CAP integrates nicely with the agentic workflow and allows us to easily expose our services as tools for the agent, while also ensuring that all the necessary service level logic and permissions are applied. Since production qualities like authentication, authorization, and database transactions are handled by CAP, we can focus on the domain.

As an experienced CAP developer you may have noticed… tool calls are simply another protocol for calling CAP services. This means we can also implement an adapter to expose a service as a collection of tools. But that's a story for another time.

Depending on your use case, there are a few advanced topics which may become important:

  • Input and Output: In our implementation, we have a single input and output for each session. If you want to have a conversation with multiple turns, you could use Chat Memory to store the conversation history and provide it as context for each turn. Chat memory can also be easily implemented as a CDS model.
  • Structured output: For generating data which can then be given to a technical system, you need to ensure that the output conforms to a specific structure. For models supporting structured output natively, this can be achieved by using AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT and providing a class. Otherwise Spring AI provides a Structured Output Converter that can convert the output to a specific class, which may need further validation.
  • Multi-agent systems: In this example, we have implemented a single agent that can perform multiple tasks. However, in more complex scenarios, you might want to implement multiple agents that specialize in different tasks and can collaborate with each other. Or you want Joule to be able to call your agent. One option is the Agent2Agent (A2A) protocol, Spring AI also has a blog post on A2A integration. And as with tools, A2A is just another protocol.

But as always, start simple and grow as you go. So go ahead and implement your own agentic system in CAP, and see how it can transform your applications!

Source link

Leave a Reply

Your email address will not be published. Required fields are marked *