Many organizations began early by developing their own AI agents and assistants, frequently using frameworks such as LangChain and LangGraph. This post demonstrates how a custom-built AI agent can be integrated into the Joule ecosystem by using the recently introduced pro-code extensibility features together with the A2A integration.
For the demonstration, a minimal ReAct agent implemented in Python with LangGraph is used.
The A2A Protocol (Agent2Agent Protocol) is an open standard developed to enable seamless communication and collaboration between autonomous AI agents built by different vendors and on different frameworks. It defines a common interaction model that allows agents to discover each other’s capabilities, exchange structured messages and tasks, and coordinate actions securely without exposing internal state or proprietary logic.
The protocol uses standardized components such as Agent Cards for capability discovery and supports multiple transport bindings including HTTP(S) and JSON-RPC 2.0. A2A is designed with enterprise requirements in mind, supporting asynchronous task workflows, secure communication, and interoperability across diverse agent ecosystems.
Many practitioners in the community have already experimented with Joule Studio as the low-code environment for extending Joule. Joule now also provides pro-code extensibility. This expands the addressable scenarios beyond classic skills, enabling more complex logic, deeper structures, richer response shaping, and integration with remote agents via A2A.
Overview of Joule Capabilities
Within the pro-code model, developers define capabilities. A capability groups the “skills” Joule can invoke in response to a user request.
Two concepts are central: scenarios and functions.
A scenario acts as the entry shell of a skill. It contains a name, a description, and a set of input parameters. During skill selection, Joule evaluates the scenario description of the available list of scenarios to pick one. The scenario itself then declares which function should run and which parameters will be filled by Joule.
A function represents the executable logic. It is composed of smaller actions, for example, calling an API, sending or formatting messages, or—now newly—invoking another remote agent.
From an engineering perspective, these artifacts are authored as YAML projects and then deployed to a Joule instance.
To support this workflow, two primary tools are available: the Joule Studio Code Editor extension for Visual Studio Code, and the Joule Studio CLI. The example here focuses primarily on using the CLI.
Now let’s go step by step:
Setup
For this exercise, there are two prerequisites:
First, you need to have an instance of Joule base instantiated on one of your BTP Subaccounts. If you do not yet have one, I recommend checking out our great mission around Joule Studio that includes a step to set up a minimal setup of Joule. Ideally, your company already enabled Joule for some of the Cloud applications, and you can utilize that instance. In the mission, they explain how to set up your user with the “enduser” role in BTP. For this exercise, we need some additional roles (“capabilityadmin”, “extensibility_developer”) as depicted below:
Second, to engage in the Pro-Code extensibility, you want to have the Joule CLI installed. Here I recommend checking out the newly released Joule Developer documentation on help.sap.
To log in against your Joule application, there are some additional steps necessary. We need to collect a set of client credentials. Since Joule is based on the new Cloud Identity Services flow, this involves a few extra steps.
1. Login to your IAS tenant:
On your BTP Subaccount where you set up Joule, you trusted an IAS tenant—found under Trust Configuration. You will need to navigate to its Admin Panel by appending /admin to the tenant URL.
2. Create new Application:
In the IAS admin panel, navigate to “Application & Resources” > “Applications”. In there, you see a full list of all registered applications on that IAS tenant. Now we need to create a new one by hitting “Create” (highlighted in red). In the pop-up, we give it any name, select OpenID Connect, and leave the rest of the fields as is.
3. Add Joule application as a dependency:
Once the Application is created, we need to add a Dependency to that Application. For that purpose, we select the newly created application and navigate to “Dependencies”. By clicking on “Add” we will be able to add a new one. In the pop-up, we need to give it the name “CLI2Joule”. This name is mandatory. Next, we select the Joule application—you will recognize your subaccount name after the “das-ias”. Finally, we can select the API and hit “Save”.
4. Create Client Secret:
The following step will be to navigate to “Client Authentication”, still under your own created app. We want to add a new Secret by clicking “Add”. Giving it an arbitrary name and ticking all the boxes will lead to its creation:
Now with that, we have all the necessary details to log in to Joule:
joule login -a https://.accounts.cloud.sap –apiurl https://.eu10.sapdas.cloud.sap -c -s -u -p –store-password
Note: The Authentication URL is the Domain of your IAS tenant, so you can easily copy it from the admin panel’s URL. While the API URL is the URL of Joule, so when opening up your Joule application from the BTP, you can copy that one:
1. Building the Agent with LangGraph
The first step in this journey is to build an AI agent and expose it via an A2A server. For this purpose, I am using the samples provided directly by the A2A project on GitHub.
Since LangGraph is a popular framework, I am using the LangGraph sample code here. However, you could replace it with any framework you prefer. Similarly, the programming language is flexible: A2A only defines the endpoint structure and schema. You can expose A2A servers using any technology you like. Sample code is available for a variety of languages, including Node.js and Java. For my demonstration, I am using Python deployed on SAP Cloud Foundry.
My project structure, adapted for deployment on Cloud Foundry, looks like this:
app/
├── agent.py
├── agent_executor.py
├── app.py
├── manifest.yaml
├── requirements.txt
├── runtime.txt
└── test_client.py
First, let’s look at the heart of our project—the agent.py:
import os
from collections.abc import AsyncIterable
from typing import Any, Literal
import httpx
from langchain_core.messages import AIMessage, ToolMessage
from langchain_core.tools import tool
from genaihub.proxy.langchain.openai import ChatOpenAI
from genaihub.proxy.core.proxyclients import getproxy_client
from langgraph.checkpoint.memory import MemorySaver
from langgraph.prebuilt import createreactagent
from pydantic import BaseModel
proxyclient = getproxy_client(‘gen-ai-hub’)
memory = MemorySaver()
@tool
def getexchangerate(
currency_from: str = ‘USD’,
currency_to: str = ‘EUR’,
currency_date: str = ‘latest’,
):
“””Use this to get current exchange rate.
Args:
currency_from: The currency to convert from (e.g., “USD”).
currency_to: The currency to convert to (e.g., “EUR”).
currency_date: The date for the exchange rate or “latest”. Defaults to
“latest”.
Returns:
A dictionary containing the exchange rate data, or an error message if
the request fails.
“””
try:
response = httpx.get(
f’https://api.frankfurter.app/{currency_date}’,
params={‘from’: currencyfrom, ‘to’: currencyto},
)
response.raiseforstatus()
data = response.json()
if ‘rates’ not in data:
return {‘error’: ‘Invalid API response format.’}
return data
except httpx.HTTPError as e:
return {‘error’: f’API request failed: {e}’}
except ValueError:
return {‘error’: ‘Invalid JSON response from API.’}
class ResponseFormat(BaseModel):
“””Respond to the user in this format.”””
status: Literal[‘inputrequired’, ‘completed’, ‘error’] = ‘inputrequired’
message: str
class CurrencyAgent:
“””CurrencyAgent – a specialized assistant for currency conversions.”””
SYSTEM_INSTRUCTION = (
‘You are a specialized assistant for currency conversions. ‘
“Your sole purpose is to use the ‘getexchangerate’ tool to answer questions about currency exchange rates. “
‘If the user asks about anything other than currency conversion or exchange rates, ‘
‘politely state that you cannot help with that topic and can only assist with currency-related queries. ‘
‘Do not attempt to answer unrelated questions or use tools for other purposes.’
)
FORMAT_INSTRUCTION = (
‘Set response status to input_required if the user needs to provide more information to complete the request.’
‘Set response status to error if there is an error while processing the request.’
‘Set response status to completed if the request is complete.’
)
def init(self):
self.model = ChatOpenAI(
proxymodelname=’gpt-4o-mini’,
proxyclient=proxyclient,
temperature=0
)
self.tools = [getexchangerate]
self.graph = createreactagent(
self.model,
tools=self.tools,
checkpointer=memory,
prompt=self.SYSTEM_INSTRUCTION,
responseformat=(self.FORMATINSTRUCTION, ResponseFormat),
)
async def stream(self, query, context_id) -> AsyncIterable[dict[str, Any]]:
inputs = {‘messages’: [(‘user’, query)]}
config = {‘configurable’: {‘threadid’: contextid}}
for item in self.graph.stream(inputs, config, stream_mode=’values’):
message = item[‘messages’][-1]
if (
isinstance(message, AIMessage)
and message.tool_calls
and len(message.tool_calls) > 0
):
yield {
‘istaskcomplete’: False,
‘requireuserinput’: False,
‘content’: ‘Looking up the exchange rates…’,
}
elif isinstance(message, ToolMessage):
yield {
‘istaskcomplete’: False,
‘requireuserinput’: False,
‘content’: ‘Processing the exchange rates..’,
}
yield self.getagentresponse(config)
def getagentresponse(self, config):
currentstate = self.graph.getstate(config)
structuredresponse = currentstate.values.get(‘structured_response’)
if structured_response and isinstance(
structured_response, ResponseFormat
):
if structuredresponse.status == ‘inputrequired’:
return {
‘istaskcomplete’: False,
‘requireuserinput’: True,
‘content’: structured_response.message,
}
if structured_response.status == ‘error’:
return {
‘istaskcomplete’: False,
‘requireuserinput’: True,
‘content’: structured_response.message,
}
if structured_response.status == ‘completed’:
return {
‘istaskcomplete’: True,
‘requireuserinput’: False,
‘content’: structured_response.message,
}
return {
‘istaskcomplete’: False,
‘requireuserinput’: True,
‘content’: (
‘We are unable to process your request at the moment. ‘
‘Please try again.’
),
}
SUPPORTEDCONTENTTYPES = [‘text’, ‘text/plain’]
In this code snippet, several key elements are visible. First, the a2a-sdk is heavily utilized, providing the base class for the AgentExecutor. This serves as the main interface for exposing an agent through the A2A SDK. Its primary method is execute, which receives a task from the A2A client, including context and task details, passes it to the agent, and processes the response. The SDK also handles sending intermittent updates and requesting user input as needed.
Finally, there is the transport layer. The app.py file exposes the Agent Card at the well-known endpoint and manages incoming requests. Here, the transport protocol can be chosen, with the default set to JSON-RPC.
import httpx
import os
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.tasks import (
BasePushNotificationSender,
InMemoryPushNotificationConfigStore,
InMemoryTaskStore,
)
from a2a.types import (
AgentCapabilities,
AgentCard,
AgentSkill,
)
from agent import CurrencyAgent
from agent_executor import CurrencyAgentExecutor
Get host and port from environment variables (Cloud Foundry sets PORT)
HOST = os.getenv(‘HOST’, ‘0.0.0.0’)
PORT = int(os.getenv(‘PORT’, 10000))
Create agent capabilities and card
capabilities = AgentCapabilities(streaming=True, push_notifications=True)
skill = AgentSkill(
id=’convert_currency’,
name=’Currency Exchange Rates Tool’,
description=’Helps with exchange values between various currencies’,
tags=[‘currency conversion’, ‘currency exchange’],
examples=[‘What is exchange rate between USD and GBP?’],
)
agent_card = AgentCard(
name=’Currency Agent’,
description=’Helps with exchange rates for currencies’,
url=f’http://localhost:443′, # is ignored by the Joule A2A integration – instead the URL from the destination is taken
version=’1.0.0′,
defaultinputmodes=CurrencyAgent.SUPPORTEDCONTENTTYPES,
defaultoutputmodes=CurrencyAgent.SUPPORTEDCONTENTTYPES,
capabilities=capabilities,
skills=[skill],
)
Create request handler and server
httpx_client = httpx.AsyncClient()
pushconfigstore = InMemoryPushNotificationConfigStore()
push_sender = BasePushNotificationSender(
httpxclient=httpxclient,
configstore=pushconfig_store
)
request_handler = DefaultRequestHandler(
agent_executor=CurrencyAgentExecutor(),
task_store=InMemoryTaskStore(),
pushconfigstore=pushconfigstore,
pushsender=pushsender
)
server = A2AStarletteApplication(
agentcard=agentcard,
httphandler=requesthandler
)
Export the ASGI app for uvicorn
app = server.build()
Again, this is mostly boilerplate code provided by the A2A SDK. We populate the Agent Card with the specific information for our use case and ensure that requests are handled appropriately. To do this, we instantiate the DefaultRequestHandler.
Finally, let’s look at the files that enable deployment. The manifest.yaml facilitates deployment by providing configuration settings and the startup command:
applications:
– name: currency-agent
memory: 512M
disk_quota: 1G
buildpacks:
– python_buildpack
env:
# Add your SAP AI Core credentials here
AICOREAUTHURL: “”
AICORECLIENTID: “”
AICORECLIENTSECRET: “”
AICORERESOURCEGROUP: “”
AICOREBASEURL: “”
command: uvicorn app:app –host 0.0.0.0 –port ${PORT}
I am using the python_buildpack in conjunction with a specific Python version specified in the runtime.txt. Since we are utilizing the foundation models of the generative AI hub of AI Core, we also ensure to pass on our credentials via environment variables.
runtime.txt:
python-3.13.9
This is necessary because the dependencies only support Python versions greater than 3.12, while Cloud Foundry currently uses an earlier version by default.
With this structure in place, the agent can be deployed to Cloud Foundry. Alternatively, it can be hosted in any other environment of your choice. Ensure to update the Agent Card with your specific URL.
2. Building the Joule Pro-Code Capability
For this example, I prepared a minimal directory and code structure that you can utilize:
currencyagentcapability/
├── functions/
│ └── currencyagentfunction.yaml
├── scenarios/
│ └── currencyagentscenario.yaml
└── capability.sapdas.yaml
Let’s start with capability.sapdas.yaml. It describes the overall capability with some descriptions, defines the version of the design-time artifacts, and lets us configure the Destination mapping against the system alias.
schema_version: 3.27.0 # be aware of the minimum version for agents
metadata:
namespace: joule.ext # very important to be in the “joule.ext” namespace for capabilities
name: currencyagentcapability
version: 1.0.0
display_name: Currency Agent Capability
description: Capability containing the currency agent
system_aliases:
CURRENCY_AGENT:
destination: CURRENCY_AGENT # this is referencing the destination name
For our example, it is important to use version 3.27.0 onwards, since this is the minimum version for the Agent integration. In addition, we specify the namespace. Here we need to use the extension namespace joule.ext; other namespaces will fail.
At the bottom, we register a system alias and map it to a destination—both of which I call CURRENCY_AGENT. The destination will be named accordingly in the subsequent step in BTP.
Next, we introduce the scenario for our agent.
currencyagentscenario.yaml
description: The Currency Agent supports converting between different currencies.
target:
name: currencyagentfunction
type: function
It basically consists of the description and the target—in this case, the currencyagentfunction. Additionally, one could maintain parameters that would be collected by the Joule Orchestrator. For our particular case, for example, we could maintain a currency pair and an amount to convert. In addition, if your agent is handling multi-turn conversations, you might want to pass the conversation ID to it via the context.
Finally, the function:
currencyagentfunction.yaml
action_groups:
– actions:
– type: agent-request # This action type allows the agent to trigger an agent
systemalias: CURRENCYAGENT # The system alias is defined in the capability.sapdas.yaml file and points to the destination of the agent
agent_type: remote # remote for code based agents
result_variable: “apiResponse” # response of the agent – can be used in subsequent actions by referring to the variable name
– type: message
message:
type: text
content: “<? apiResponse.body.artifacts[0].parts[0].text ?>” # extracting textual response from the agent
In here, we define actions, and the most important one is the agent-request. It will invoke the Agent via the A2A integration. We specify the system alias, the type as remote, and which variable should hold the response.
Note: The response from the agent now needs to be “formatted” in this function.
In this super minimalistic case, I just output the result body in a static message sent to the user.
Understanding the Joule Agent Result Variable
When working with the agent-request action, the result_variable is the connection between the output of the agent and the formatting of the response to Joules Webclient. In that result variable we have the following:
Key Components of the Response
* Body: The core container for the task data. * History: A log of the conversation turns between the user and the agent. * Artifacts: The final, structured answers or generated content. * Status: The current state of the task (e.g., completed).
Example
Below is a sample JSON payload returned by a Joule agent after a currency conversion request:
{
“headers”: {},
“body”: {
“kind”: “task”,
“contextId”: “98fa8d22-0e99-4efe-a1a9-fff535e7db9d”,
“history”: [
{
“role”: “user”,
“kind”: “message”,
“parts”: [{“kind”: “text”, “text”: “Please convert 10 USD to euros”}],
“messageId”: “eb8b5afa-63cb-4cc5-9b24-38f85e782c95”
},
{
“role”: “agent”,
“kind”: “message”,
“parts”: [{“kind”: “text”, “text”: “Looking up the exchange rates…”}]
}
],
“id”: “f0d78896-0eab-472f-8a85-73050948c0a2”,
“artifacts”: [
{
“name”: “conversion_result”,
“parts”: [
{
“kind”: “text”,
“text”: “10 USD is approximately 8.43 EUR based on the current exchange rate.”
}
],
“artifactId”: “b8d6c02b-1646-4676-969b-ca07f7df2af0”
}
],
“status”: {
“state”: “completed”,
“timestamp”: “2026-02-16T11:45:20.883269+00:00”
}
}
}
Extracting Specific Information
Developers can use simple dot-notation scripting to drill down into this variable and extract specific data for the UI.
Example: To grab the final conversion text from the JSON above, you would navigate the path:apiResponse.body.artifacts[0].parts[0].text
This allows you to bypass the background “thought” process or status updates and display only the exact information the user is looking for in a clean, conversational message.
3. Destination Creation
To bridge Joule with our remote Python agent, we need to establish a connection via the SAP BTP Destination Service. This follows the standard procedure for creating an HTTP destination within your BTP Subaccount.
In the BTP Cockpit, navigate to Connectivity > Destinations and create a new destination with the following details:
Name: CURRENCY_AGENT (This must match the system alias we defined in our capability YAML).
Type: HTTP
URL: The base URL where your agent is hosted (e.g., your Cloud Foundry app URL).
Proxy Type: Internet
Authentication: For this demonstration and to keep things simple, I have selected NoAuthentication.
The A2A integration handles the heavy lifting of discovery. When Joule triggers the agent request, it uses the URL provided in this destination to locate the well-known.json path (the discovery endpoint) of your agent.
From there, Joule retrieves the Agent Card, identifies the specific communication URL defined within that card (+ preferred protocol), and subsequently initiates A2A-compliant messaging to send tasks and receive responses from your agent.
4. Deploy the Joule Capability
Now that the Joule capability is built and the destination is configured to point toward our agent on Cloud Foundry, we can bring everything together. To do this, we will use the Joule CLI to deploy the capability to our instance.
Deployment Workflow
The Joule CLI handles the lifecycle of your capability through three main phases:
Authentication: Use joule login to authenticate with the specific Joule instance where you want to deploy.Compilation: The source files must be compiled into a .daar file (Design-time Artifact Archive), which is the deployable format Joule requires.Deployment: The archive is uploaded and registered to the assistant.
Compile and deploy can be combined with the following command:
joule deploy -c -n “”
-c (Compile): Tells the CLI to first package your YAML files into the .daar format.
-n (Name): By specifying a bot name, you are creating a standalone assistant for testing purposes.
Sample Output:
joule deploy -c -n “currencyagenttest”
✔ Building designtime artifact (currencyagentcapability)
✔ Trigger compilation (C:Githubjoule-code-based-agent-samplecurrencyagentcapability)
✔ Compiled (C:Githubjoule-code-based-agent-samplecurrencyagentcapability)
Detailed logs:
WARNINGS:
Message: DTA did not define an optional i18n folder
Path:
Category: I18N
Severity: LOW
✔ Downloaded runtime artifact (joule.extcurrencyagentcapability1.0.0.daar)
✔ Building runtime artifact
> joule.extcurrencyagentcapability1.0.0.daar added to the RTA
✔ Triggering deployment (currencyagenttest)
✔ Your digital assistant (currencyagenttest) deployed successfully
That would have deployed my currencyagenttest assistant that I can then launch.
Note: Test Assistant vs. Production Update
By default, Joule uses the sap_digital_assistant for live enterprise scenarios. (Also when opening Joule with /joule) When you are first developing, using joule deploy -n allows you to test your logic in a isolated “sandbox” assistant without affecting the standard content.
Once you are ready to move your capability into the main environment alongside SAP’s standard content, you would typically use:
joule update “sapdigitalassistant” –capability-file capability.sapdas.yaml
This command pushes your capability into the existing sapdigitalassistant rather than creating a new standalone bot.
Launching the Assistant
After a successful deployment, the CLI will provide a URL to access your new assistant. When you open this link, you will see the assistant name reflected in the URL path.
joule launch “currencyagenttest”
✔ Launching: https://joule-pro-code-9r3t5r7a.eu10.sapdas.cloud.sap/webclient/standalone/currencyagenttest
From here, you can begin interacting with your custom LangGraph agent directly through the Joule interface.
Conclusion
Finally, we can test our agent directly from within Joule. For this example, I am using a standalone Joule instance. With the necessary end-user role assigned to my user, I can open the Joule web client and ask Joule for a currency conversion:
As you can see, the scenario is selected, the function is executed, and the agent request action delegates the call to my LangGraph agent deployed on Cloud Foundry. The response is generated there and sent back to Joule.
Nice! This opens up plenty of possibilities for extending Joule with custom-built agents.
I hope you found this insightful. Since this blog covered only a very minimal example, stay tuned for additional posts. There are many topics to dive deeper into, such as authentication flows toward SAP systems via principal propagation, managing the agent context across multi-turn conversations, and response formats beyond simple text.
Many organizations began early by developing their own AI agents and assistants, frequently using frameworks such as LangChain and LangGraph. This post demonstrates how a custom-built AI agent can be integrated into the Joule ecosystem by using the recently introduced pro-code extensibility features together with the A2A integration.For the demonstration, a minimal ReAct agent implemented in Python with LangGraph is used.The A2A Protocol (Agent2Agent Protocol) is an open standard developed to enable seamless communication and collaboration between autonomous AI agents built by different vendors and on different frameworks. It defines a common interaction model that allows agents to discover each other’s capabilities, exchange structured messages and tasks, and coordinate actions securely without exposing internal state or proprietary logic.The protocol uses standardized components such as Agent Cards for capability discovery and supports multiple transport bindings including HTTP(S) and JSON-RPC 2.0. A2A is designed with enterprise requirements in mind, supporting asynchronous task workflows, secure communication, and interoperability across diverse agent ecosystems.Many practitioners in the community have already experimented with Joule Studio as the low-code environment for extending Joule. Joule now also provides pro-code extensibility. This expands the addressable scenarios beyond classic skills, enabling more complex logic, deeper structures, richer response shaping, and integration with remote agents via A2A.Overview of Joule CapabilitiesWithin the pro-code model, developers define capabilities. A capability groups the “skills” Joule can invoke in response to a user request.Two concepts are central: scenarios and functions.A scenario acts as the entry shell of a skill. It contains a name, a description, and a set of input parameters. During skill selection, Joule evaluates the scenario description of the available list of scenarios to pick one. The scenario itself then declares which function should run and which parameters will be filled by Joule.A function represents the executable logic. It is composed of smaller actions, for example, calling an API, sending or formatting messages, or—now newly—invoking another remote agent.From an engineering perspective, these artifacts are authored as YAML projects and then deployed to a Joule instance.To support this workflow, two primary tools are available: the Joule Studio Code Editor extension for Visual Studio Code, and the Joule Studio CLI. The example here focuses primarily on using the CLI.Now let’s go step by step:SetupFor this exercise, there are two prerequisites:First, you need to have an instance of Joule base instantiated on one of your BTP Subaccounts. If you do not yet have one, I recommend checking out our great mission around Joule Studio that includes a step to set up a minimal setup of Joule. Ideally, your company already enabled Joule for some of the Cloud applications, and you can utilize that instance. In the mission, they explain how to set up your user with the “enduser” role in BTP. For this exercise, we need some additional roles (“capabilityadmin”, “extensibility_developer”) as depicted below:Second, to engage in the Pro-Code extensibility, you want to have the Joule CLI installed. Here I recommend checking out the newly released Joule Developer documentation on help.sap.To log in against your Joule application, there are some additional steps necessary. We need to collect a set of client credentials. Since Joule is based on the new Cloud Identity Services flow, this involves a few extra steps.1. Login to your IAS tenant:On your BTP Subaccount where you set up Joule, you trusted an IAS tenant—found under Trust Configuration. You will need to navigate to its Admin Panel by appending /admin to the tenant URL.2. Create new Application:In the IAS admin panel, navigate to “Application & Resources” > “Applications”. In there, you see a full list of all registered applications on that IAS tenant. Now we need to create a new one by hitting “Create” (highlighted in red). In the pop-up, we give it any name, select OpenID Connect, and leave the rest of the fields as is.3. Add Joule application as a dependency:Once the Application is created, we need to add a Dependency to that Application. For that purpose, we select the newly created application and navigate to “Dependencies”. By clicking on “Add” we will be able to add a new one. In the pop-up, we need to give it the name “CLI2Joule”. This name is mandatory. Next, we select the Joule application—you will recognize your subaccount name after the “das-ias”. Finally, we can select the API and hit “Save”.4. Create Client Secret:The following step will be to navigate to “Client Authentication”, still under your own created app. We want to add a new Secret by clicking “Add”. Giving it an arbitrary name and ticking all the boxes will lead to its creation:Now with that, we have all the necessary details to log in to Joule:joule login -a https://.accounts.cloud.sap –apiurl https://.eu10.sapdas.cloud.sap -c -s -u -p –store-passwordNote: The Authentication URL is the Domain of your IAS tenant, so you can easily copy it from the admin panel’s URL. While the API URL is the URL of Joule, so when opening up your Joule application from the BTP, you can copy that one:1. Building the Agent with LangGraphThe first step in this journey is to build an AI agent and expose it via an A2A server. For this purpose, I am using the samples provided directly by the A2A project on GitHub.Since LangGraph is a popular framework, I am using the LangGraph sample code here. However, you could replace it with any framework you prefer. Similarly, the programming language is flexible: A2A only defines the endpoint structure and schema. You can expose A2A servers using any technology you like. Sample code is available for a variety of languages, including Node.js and Java. For my demonstration, I am using Python deployed on SAP Cloud Foundry.My project structure, adapted for deployment on Cloud Foundry, looks like this:app/
├── agent.py
├── agent_executor.py
├── app.py
├── manifest.yaml
├── requirements.txt
├── runtime.txt
└── test_client.pyFirst, let’s look at the heart of our project—the agent.py:import os
from collections.abc import AsyncIterable
from typing import Any, Literal
import httpx
from langchain_core.messages import AIMessage, ToolMessage
from langchain_core.tools import tool
from genaihub.proxy.langchain.openai import ChatOpenAI
from genaihub.proxy.core.proxyclients import getproxy_client
from langgraph.checkpoint.memory import MemorySaver
from langgraph.prebuilt import createreactagent
from pydantic import BaseModel
proxyclient = getproxy_client(‘gen-ai-hub’)
memory = MemorySaver()
@tool
def getexchangerate(
currency_from: str = ‘USD’,
currency_to: str = ‘EUR’,
currency_date: str = ‘latest’,
):
“””Use this to get current exchange rate.
Args:
currency_from: The currency to convert from (e.g., “USD”).
currency_to: The currency to convert to (e.g., “EUR”).
currency_date: The date for the exchange rate or “latest”. Defaults to
“latest”.
Returns:
A dictionary containing the exchange rate data, or an error message if
the request fails.
“””
try:
response = httpx.get(
f’https://api.frankfurter.app/{currency_date}’,
params={‘from’: currencyfrom, ‘to’: currencyto},
)
response.raiseforstatus()
data = response.json()
if ‘rates’ not in data:
return {‘error’: ‘Invalid API response format.’}
return data
except httpx.HTTPError as e:
return {‘error’: f’API request failed: {e}’}
except ValueError:
return {‘error’: ‘Invalid JSON response from API.’}
class ResponseFormat(BaseModel):
“””Respond to the user in this format.”””
status: Literal[‘inputrequired’, ‘completed’, ‘error’] = ‘inputrequired’
message: str
class CurrencyAgent:
“””CurrencyAgent – a specialized assistant for currency conversions.”””
SYSTEM_INSTRUCTION = (
‘You are a specialized assistant for currency conversions. ‘
“Your sole purpose is to use the ‘getexchangerate’ tool to answer questions about currency exchange rates. “
‘If the user asks about anything other than currency conversion or exchange rates, ‘
‘politely state that you cannot help with that topic and can only assist with currency-related queries. ‘
‘Do not attempt to answer unrelated questions or use tools for other purposes.’
)
FORMAT_INSTRUCTION = (
‘Set response status to input_required if the user needs to provide more information to complete the request.’
‘Set response status to error if there is an error while processing the request.’
‘Set response status to completed if the request is complete.’
)
def init(self):
self.model = ChatOpenAI(
proxymodelname=’gpt-4o-mini’,
proxyclient=proxyclient,
temperature=0
)
self.tools = [getexchangerate]
self.graph = createreactagent(
self.model,
tools=self.tools,
checkpointer=memory,
prompt=self.SYSTEM_INSTRUCTION,
responseformat=(self.FORMATINSTRUCTION, ResponseFormat),
)
async def stream(self, query, context_id) -> AsyncIterable[dict[str, Any]]:
inputs = {‘messages’: [(‘user’, query)]}
config = {‘configurable’: {‘threadid’: contextid}}
for item in self.graph.stream(inputs, config, stream_mode=’values’):
message = item[‘messages’][-1]
if (
isinstance(message, AIMessage)
and message.tool_calls
and len(message.tool_calls) > 0
):
yield {
‘istaskcomplete’: False,
‘requireuserinput’: False,
‘content’: ‘Looking up the exchange rates…’,
}
elif isinstance(message, ToolMessage):
yield {
‘istaskcomplete’: False,
‘requireuserinput’: False,
‘content’: ‘Processing the exchange rates..’,
}
yield self.getagentresponse(config)
def getagentresponse(self, config):
currentstate = self.graph.getstate(config)
structuredresponse = currentstate.values.get(‘structured_response’)
if structured_response and isinstance(
structured_response, ResponseFormat
):
if structuredresponse.status == ‘inputrequired’:
return {
‘istaskcomplete’: False,
‘requireuserinput’: True,
‘content’: structured_response.message,
}
if structured_response.status == ‘error’:
return {
‘istaskcomplete’: False,
‘requireuserinput’: True,
‘content’: structured_response.message,
}
if structured_response.status == ‘completed’:
return {
‘istaskcomplete’: True,
‘requireuserinput’: False,
‘content’: structured_response.message,
}
return {
‘istaskcomplete’: False,
‘requireuserinput’: True,
‘content’: (
‘We are unable to process your request at the moment. ‘
‘Please try again.’
),
}
SUPPORTEDCONTENTTYPES = [‘text’, ‘text/plain’]In this code snippet, several key elements are visible. First, the a2a-sdk is heavily utilized, providing the base class for the AgentExecutor. This serves as the main interface for exposing an agent through the A2A SDK. Its primary method is execute, which receives a task from the A2A client, including context and task details, passes it to the agent, and processes the response. The SDK also handles sending intermittent updates and requesting user input as needed.Finally, there is the transport layer. The app.py file exposes the Agent Card at the well-known endpoint and manages incoming requests. Here, the transport protocol can be chosen, with the default set to JSON-RPC.import httpx
import os
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.tasks import (
BasePushNotificationSender,
InMemoryPushNotificationConfigStore,
InMemoryTaskStore,
)
from a2a.types import (
AgentCapabilities,
AgentCard,
AgentSkill,
)
from agent import CurrencyAgent
from agent_executor import CurrencyAgentExecutor
Get host and port from environment variables (Cloud Foundry sets PORT)
HOST = os.getenv(‘HOST’, ‘0.0.0.0’)
PORT = int(os.getenv(‘PORT’, 10000))
Create agent capabilities and card
capabilities = AgentCapabilities(streaming=True, push_notifications=True)
skill = AgentSkill(
id=’convert_currency’,
name=’Currency Exchange Rates Tool’,
description=’Helps with exchange values between various currencies’,
tags=[‘currency conversion’, ‘currency exchange’],
examples=[‘What is exchange rate between USD and GBP?’],
)
agent_card = AgentCard(
name=’Currency Agent’,
description=’Helps with exchange rates for currencies’,
url=f’http://localhost:443′, # is ignored by the Joule A2A integration – instead the URL from the destination is taken
version=’1.0.0′,
defaultinputmodes=CurrencyAgent.SUPPORTEDCONTENTTYPES,
defaultoutputmodes=CurrencyAgent.SUPPORTEDCONTENTTYPES,
capabilities=capabilities,
skills=[skill],
)
Create request handler and server
httpx_client = httpx.AsyncClient()
pushconfigstore = InMemoryPushNotificationConfigStore()
push_sender = BasePushNotificationSender(
httpxclient=httpxclient,
configstore=pushconfig_store
)
request_handler = DefaultRequestHandler(
agent_executor=CurrencyAgentExecutor(),
task_store=InMemoryTaskStore(),
pushconfigstore=pushconfigstore,
pushsender=pushsender
)
server = A2AStarletteApplication(
agentcard=agentcard,
httphandler=requesthandler
)
Export the ASGI app for uvicorn
app = server.build()Again, this is mostly boilerplate code provided by the A2A SDK. We populate the Agent Card with the specific information for our use case and ensure that requests are handled appropriately. To do this, we instantiate the DefaultRequestHandler.Finally, let’s look at the files that enable deployment. The manifest.yaml facilitates deployment by providing configuration settings and the startup command:applications:
– name: currency-agent
memory: 512M
disk_quota: 1G
buildpacks:
– python_buildpack
env:
# Add your SAP AI Core credentials here
AICOREAUTHURL: “”
AICORECLIENTID: “”
AICORECLIENTSECRET: “”
AICORERESOURCEGROUP: “”
AICOREBASEURL: “”
command: uvicorn app:app –host 0.0.0.0 –port ${PORT}I am using the python_buildpack in conjunction with a specific Python version specified in the runtime.txt. Since we are utilizing the foundation models of the generative AI hub of AI Core, we also ensure to pass on our credentials via environment variables.runtime.txt:python-3.13.9This is necessary because the dependencies only support Python versions greater than 3.12, while Cloud Foundry currently uses an earlier version by default.With this structure in place, the agent can be deployed to Cloud Foundry. Alternatively, it can be hosted in any other environment of your choice. Ensure to update the Agent Card with your specific URL.2. Building the Joule Pro-Code CapabilityFor this example, I prepared a minimal directory and code structure that you can utilize:currencyagentcapability/
├── functions/
│ └── currencyagentfunction.yaml
├── scenarios/
│ └── currencyagentscenario.yaml
└── capability.sapdas.yamlLet’s start with capability.sapdas.yaml. It describes the overall capability with some descriptions, defines the version of the design-time artifacts, and lets us configure the Destination mapping against the system alias.schema_version: 3.27.0 # be aware of the minimum version for agents
metadata:
namespace: joule.ext # very important to be in the “joule.ext” namespace for capabilities
name: currencyagentcapability
version: 1.0.0
display_name: Currency Agent Capability
description: Capability containing the currency agent
system_aliases:
CURRENCY_AGENT:
destination: CURRENCY_AGENT # this is referencing the destination nameFor our example, it is important to use version 3.27.0 onwards, since this is the minimum version for the Agent integration. In addition, we specify the namespace. Here we need to use the extension namespace joule.ext; other namespaces will fail.At the bottom, we register a system alias and map it to a destination—both of which I call CURRENCY_AGENT. The destination will be named accordingly in the subsequent step in BTP.Next, we introduce the scenario for our agent.currencyagentscenario.yamldescription: The Currency Agent supports converting between different currencies.
target:
name: currencyagentfunction
type: functionIt basically consists of the description and the target—in this case, the currencyagentfunction. Additionally, one could maintain parameters that would be collected by the Joule Orchestrator. For our particular case, for example, we could maintain a currency pair and an amount to convert. In addition, if your agent is handling multi-turn conversations, you might want to pass the conversation ID to it via the context.Finally, the function:currencyagentfunction.yamlaction_groups:
– actions:
– type: agent-request # This action type allows the agent to trigger an agent
systemalias: CURRENCYAGENT # The system alias is defined in the capability.sapdas.yaml file and points to the destination of the agent
agent_type: remote # remote for code based agents
result_variable: “apiResponse” # response of the agent – can be used in subsequent actions by referring to the variable name
– type: message
message:
type: text
content: “<? apiResponse.body.artifacts[0].parts[0].text ?>” # extracting textual response from the agentIn here, we define actions, and the most important one is the agent-request. It will invoke the Agent via the A2A integration. We specify the system alias, the type as remote, and which variable should hold the response.Note: The response from the agent now needs to be “formatted” in this function.In this super minimalistic case, I just output the result body in a static message sent to the user.Understanding the Joule Agent Result VariableWhen working with the agent-request action, the result_variable is the connection between the output of the agent and the formatting of the response to Joules Webclient. In that result variable we have the following:Key Components of the Response* Body: The core container for the task data. * History: A log of the conversation turns between the user and the agent. * Artifacts: The final, structured answers or generated content. * Status: The current state of the task (e.g., completed).ExampleBelow is a sample JSON payload returned by a Joule agent after a currency conversion request:{
“headers”: {},
“body”: {
“kind”: “task”,
“contextId”: “98fa8d22-0e99-4efe-a1a9-fff535e7db9d”,
“history”: [
{
“role”: “user”,
“kind”: “message”,
“parts”: [{“kind”: “text”, “text”: “Please convert 10 USD to euros”}],
“messageId”: “eb8b5afa-63cb-4cc5-9b24-38f85e782c95”
},
{
“role”: “agent”,
“kind”: “message”,
“parts”: [{“kind”: “text”, “text”: “Looking up the exchange rates…”}]
}
],
“id”: “f0d78896-0eab-472f-8a85-73050948c0a2”,
“artifacts”: [
{
“name”: “conversion_result”,
“parts”: [
{
“kind”: “text”,
“text”: “10 USD is approximately 8.43 EUR based on the current exchange rate.”
}
],
“artifactId”: “b8d6c02b-1646-4676-969b-ca07f7df2af0”
}
],
“status”: {
“state”: “completed”,
“timestamp”: “2026-02-16T11:45:20.883269+00:00”
}
}
}Extracting Specific InformationDevelopers can use simple dot-notation scripting to drill down into this variable and extract specific data for the UI.Example: To grab the final conversion text from the JSON above, you would navigate the path:apiResponse.body.artifacts[0].parts[0].textThis allows you to bypass the background “thought” process or status updates and display only the exact information the user is looking for in a clean, conversational message.3. Destination CreationTo bridge Joule with our remote Python agent, we need to establish a connection via the SAP BTP Destination Service. This follows the standard procedure for creating an HTTP destination within your BTP Subaccount.In the BTP Cockpit, navigate to Connectivity > Destinations and create a new destination with the following details:Name: CURRENCY_AGENT (This must match the system alias we defined in our capability YAML). Type: HTTP URL: The base URL where your agent is hosted (e.g., your Cloud Foundry app URL). Proxy Type: InternetAuthentication: For this demonstration and to keep things simple, I have selected NoAuthentication.The A2A integration handles the heavy lifting of discovery. When Joule triggers the agent request, it uses the URL provided in this destination to locate the well-known.json path (the discovery endpoint) of your agent.From there, Joule retrieves the Agent Card, identifies the specific communication URL defined within that card (+ preferred protocol), and subsequently initiates A2A-compliant messaging to send tasks and receive responses from your agent.4. Deploy the Joule CapabilityNow that the Joule capability is built and the destination is configured to point toward our agent on Cloud Foundry, we can bring everything together. To do this, we will use the Joule CLI to deploy the capability to our instance.Deployment WorkflowThe Joule CLI handles the lifecycle of your capability through three main phases:Authentication: Use joule login to authenticate with the specific Joule instance where you want to deploy.Compilation: The source files must be compiled into a .daar file (Design-time Artifact Archive), which is the deployable format Joule requires.Deployment: The archive is uploaded and registered to the assistant.Compile and deploy can be combined with the following command:joule deploy -c -n “”-c (Compile): Tells the CLI to first package your YAML files into the .daar format.-n (Name): By specifying a bot name, you are creating a standalone assistant for testing purposes.Sample Output:joule deploy -c -n “currencyagenttest”
✔ Building designtime artifact (currencyagentcapability)
✔ Trigger compilation (C:Githubjoule-code-based-agent-samplecurrencyagentcapability)
✔ Compiled (C:Githubjoule-code-based-agent-samplecurrencyagentcapability)
Detailed logs:
WARNINGS:
Message: DTA did not define an optional i18n folder
Path:
Category: I18N
Severity: LOW
✔ Downloaded runtime artifact (joule.extcurrencyagentcapability1.0.0.daar)
✔ Building runtime artifact
> joule.extcurrencyagentcapability1.0.0.daar added to the RTA
✔ Triggering deployment (currencyagenttest)
✔ Your digital assistant (currencyagenttest) deployed successfullyThat would have deployed my currencyagenttest assistant that I can then launch.Note: Test Assistant vs. Production UpdateBy default, Joule uses the sap_digital_assistant for live enterprise scenarios. (Also when opening Joule with /joule) When you are first developing, using joule deploy -n allows you to test your logic in a isolated “sandbox” assistant without affecting the standard content.Once you are ready to move your capability into the main environment alongside SAP’s standard content, you would typically use:joule update “sapdigitalassistant” –capability-file capability.sapdas.yamlThis command pushes your capability into the existing sapdigitalassistant rather than creating a new standalone bot.Launching the AssistantAfter a successful deployment, the CLI will provide a URL to access your new assistant. When you open this link, you will see the assistant name reflected in the URL path.joule launch “currencyagenttest”
✔ Launching: https://joule-pro-code-9r3t5r7a.eu10.sapdas.cloud.sap/webclient/standalone/currencyagenttestFrom here, you can begin interacting with your custom LangGraph agent directly through the Joule interface.ConclusionFinally, we can test our agent directly from within Joule. For this example, I am using a standalone Joule instance. With the necessary end-user role assigned to my user, I can open the Joule web client and ask Joule for a currency conversion:As you can see, the scenario is selected, the function is executed, and the agent request action delegates the call to my LangGraph agent deployed on Cloud Foundry. The response is generated there and sent back to Joule.Nice! This opens up plenty of possibilities for extending Joule with custom-built agents.I hope you found this insightful. Since this blog covered only a very minimal example, stay tuned for additional posts. There are many topics to dive deeper into, such as authentication flows toward SAP systems via principal propagation, managing the agent context across multi-turn conversations, and response formats beyond simple text. Read More Technology Blog Posts by SAP articles
#SAP
#SAPTechnologyblog