Agentic AI is the start of a new era for business productivity. Using the Agent Builder in Joule Studio, businesses will be able to leverage AI agents to their fullest potential.
"While many in the software industry talk about AI agents these days, I can assure you, Joule will be the champion of them all. So far, we have added over 500 skills to Joule and we are well on track to cover 80% of the most frequent business and analytical transactions by the end of this year." - Christian Klein
This quote illustrates the position of Joule Studio as an absolute frontrunner especially when it comes to real world value.
The Agent builder will enable all employees regardless of technical background to harness the power of agentic AI grounded entirely within the business process.
Purpose of this blog
While Joule Studio is the absolute powerhouse for agentic AI, in this blog I want to illustrate how you can build a simple code-based agent that integrates into the BTP Services with tools that are available today. Such a code-based agent is highly customizable and can be tailored exactly to your needs.
For this purpose I will use the Python framework LangGraph, the Generative AI Hub SDK and the SuccessFactors Employee Central API. I will walk you through the implementation steps and provide as much background knowledge as necessary to understand this simple example
Use case
The use case for this demo is that of an agent dedicated to assist the user with their timesheet management.
This is a scenario where llm-based agents shine as we need to process natural language input. Additionally dynamic decision-making is required, albeit to a limited extent, e.g. the agent needs to decide on the fly whether enough information was provided by the user.
The agent's primary capabilities include:
- Retrieving Timesheet Data: Retrieving records from the SuccessFactor API containing the user's timesheet data.
- Logging work hours: Log works hours by posting records to the SuccessFactor API based on user input.
While there are other use cases that benefit more from agenticness, this demo serves the purpose of getting a good grasp of the core concepts of LangGraph by building a custom agent with human-in-the-loop control mechanisms.
A demonstration of the agent's capabilities can be found here.
Core concept
As the name suggests, LangGraph is built around the idea of orchestrating language model workflows as a graph.
Each node can be understood as a unit designed for a specific tasks, such as a language model invocation or a tool execution. On the other hand, the edges define the flow of information. LangGraph ships a prebuilt react agent, broadly following the paper ReAct: Synergizing Reasoning and Acting in Language Models (Yao et al., 2022). The paper outlines a framework that integrates reasoning and acting in language models by interleaving reasoning with task-specific actions. This prebuilt agent defines an agent and a tool node. The agent node handles LLM execution. The language model decides what tools to execute. If tool calls are present in the language model response, the execution is routed to the tools node where the tools are executed. Otherwise the agent finishes executions.
Generally, the agent iteratively executes multiple tools until it retrieved enough information or executed all relevant actions.
Our Agent architecture
We adapt the prebuilt react agent slightly by adding a review node enabling human-in-the-loop interactions. This is crucial as we want to prompt the user for confirmation before executing mutative actions.
Implementation
In the following I will expand on the implementation of the agent. To keep it simple I will leave out the frontend implementation. However, if you are interested, you can find the code for the frontend in the Git repository. The adjustments that need to be made to our agent to integrate with the frontend as well as our tool definitions can be found in the appendix. All code in this demo can be found in this Git Repository.
Setup
Prerequisites
What you should bring:
- (Limited) Experience with Python
- SAP Account
- Access to the Generative AI Hub and instance keys
Environment Setup
Retrieve API Key
The SuccessFactors API we will use, comes with a preconfigured sandbox environment. To have access to this sandbox you have to login with your SAP Account and retrieve your API Key from the Payroll Time Sheet Page.
With the API Key set this environment variable or initialize the variable with your API Key in the Python file containing the tools:
- PAYROLL_API_KEY: This is the API key you retrieved.
In a production environment you would, of course, retrieve your actual API Key.
Configure SAP AI Core
Follow all the steps under Initial Setup to configure your SAP AI Core, if you have not already done so.
With your service key follow the configuration section in this Documentation to set the following environment variables:
- AICORE_CLIENT_ID: This represents the client ID.
- AICORE_CLIENT_SECRET: This stands for the client secret.
- AICORE_AUTH_URL: This is the URL used to retrieve a token using the client ID and secret.
- AICORE_BASE_URL: This is the URL of the service (with suffix /v2).
- AICORE_RESOURCE_GROUP: This represents the resource group that should be used. (The standard resource group is "default")
Install python dependencies
Finally, you will need to install the required Python packages with a package manager of your choice.
pip install "langchain", "langgraph", "generative-ai-hub-sdk[all]"For the frontend (Optional)
pip install "streamlit"Initializing the LLM
First, we need to initialize the large language model the agent will use to perform actions and respond to the users request. Here we use gpt-4o as the underlying language model, a maximum token count of 1024 and a temperature of zero to minimize uncertainty. A temperature of zero will lead to more consistent results. However, the model output is not absolutely deterministic as the underlying calculations are inherently indeterministic.
Note: In order to initialize the language model the relevant environment variables need to be set (see Setup).
After initializing the LLM, we need give the agent access to the necessary tools.
We import three functions. get_records, post_records, and get_today from the time_tools module. These functions interact with the SuccessFactors Employee Central API:
- get_records: Retrieves existing timesheet entries.
- post_records: Submits new timesheet entries.
- get_today: Fetches the current date.
The definition of these tools can be found in the appendix.
We bind the tools to the LLM. This allows the model to specify tool calls when it deems relevant.
For a more detailed view on the tools see the appendix.
import uuid
from typing import cast, Literal
from IPython.core.display_functions import display
from gen_ai_hub.proxy.langchain import init_llm
from langchain_core.messages import AIMessage, ToolMessage
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.memory import MemorySaver
from langgraph.constants import START, END
from langgraph.graph import StateGraph
from langgraph.prebuilt import ToolNode
from langgraph.prebuilt.chat_agent_executor import AgentState
from langgraph.types import interrupt, Command, Send
from pydantic import Field, BaseModelllm = init_llm('gpt-4o', max_tokens=1024, temperature=0)
from time_tools import get_records, post_records, get_today
agent_tools = [get_records, post_records, get_today]
agent_llm = llm.bind_tools(agent_tools)Creating a system prompt
In order to ensure consistent and useful behaviour we need to define a well structured system prompt next.
Generally, you should:
- Define the role and persona.
- Establish context and objectives
- Outline clear instructions and constraints
- Provide examples of ideal responses (Optional)
By utilizing few-shot prompting model performance can be hugely improved. It also makes sense to encourage iterative clarification.
system_prompt ="""
Role and Objective:
- You are a helpful AI Agent dedicated to assisting users with their timesheet management.
- Your primary tasks include retrieving and posting timesheet data based on user requests.
Responsibilities:
- Logging Work Time: Only log actual work time. Do not include any breaks.
- Data Handling: When posting records, execute as many post_records calls in parallel as possible using the provided information.
- Automatic Confirmation: When a post_records call is made, the user is automatically asked for confirmation over a GUI; do not prompt for confirmation.
Interaction Guidelines:
- Language Consistency: Always respond in the same language as the user.
- Transparency: Provide an explanation of what you are doing with every response.
- Clarity and Accuracy:
- If any part of the user’s request is ambiguous (for example, missing dates or unclear work times), ask clarifying questions rather than making assumptions.
- Ensure all necessary details are provided before proceeding with any action.
"""
prompt = ChatPromptTemplate.from_messages([
('system', system_prompt),
MessagesPlaceholder(variable_name='msg')
])Defining the agent's nodes
Now we come to actually building our agent.
As previously outlined, our agent consists of three nodes:
- the agent node: executing LLM calls,
- the review node: prompting the user for confirmation before mutative action
- the tool node: executing tools specified in the agent's response
When a node is called it is passed the graph state. We use the prebuilt AgentState which defines a variable messages by subclassing TypedDict. This is simply a list of messages which is passed to the language model at every invocation.
The agent node
In the agent node we simply call the language model, we initialized before, with the system prompt and the message history. We then update the graph state by returning the models response. The response is appended to the list of messages.
Each key in the graph state has its own reducer function. A reducer function defines how a variable in the graph state is updated. The prebuilt AgentState defines operator.add as the reducer for messages. Because of this returning [response] appends the list of messages with the response instead of overwriting it.
def agent(state: AgentState, config: RunnableConfig):
model_input = prompt.invoke({'msg': state['messages']})
response = cast(AIMessage, agent_llm.invoke(model_input, config))
response.name = "agent"
return {"messages": [response]}The review node
Next we define the review node. For simplicity, we use only text input for verification. A more robust approach (used in the actual demo) can be found in the appendix. Here we use an additional language model for verification.
We use a workaround to get structured output from our model as of now structured_output is not supported. For this we bind a tool the model should use to structure its response.
class UserAffirmation(BaseModel):
"""Always use this tool to structure your response."""
user_affirmation: bool = Field(description="Whether the user confirmed the action.")
explanation: str = Field(description="An explanation of your decision.")
verification_llm = llm.bind_tools([UserAffirmation])When control is passed to the review node, the post requests from the agent's response are fetched. If no post request is present, execution is resumed with the tools node. This is done by returning a Send object which routes execution. Otherwise the user is asked for confirmation with an interrupt. In contrast to Python's interrupt, execution is not resumed from the interrupt point, but rather the last node is executed again from top to bottom. This can be a common pitfall. When the user has supplied a response, we call a language model to process the user's input and retrieve the structured response. If the user approves we continue execution with the tool node. Otherwise, we update the state by appending a Tool Message and resume execution with the agent node. It is strictly necessary to append a Tool Message as most model providers require every tool call to be accompanied by a corresponding Tool Message.
from typing import Union
def human_review(state: AgentState) -> Command[Literal["agent", "tools"]]:
last_message = state["messages"][-1]
post_requests = [tool_call for tool_call in last_message.tool_calls if tool_call['name'] == 'post_records']
if len(post_requests) > 0:
confirmation_messages = [post_requests["args"]["confirmation_messages"] for post_reqeuest in post_requests]
user_review = interrupt({"task": "Review the action.",
"action": confirmation_messages})
output = verification_llm.invoke(
[('user', user_review), ('system', 'Verify whether the user wants to continue with the action.')])
should_continue = output.tool_calls[0]['args']['user_affirmation']
print(f"Model explanation: {output.tool_calls[0]['args']['explanation']}")
if should_continue:
return Send(node='tools', arg=state)
else:
return Command(update={"messages": [ToolMessage('User did not confirm action.', tool_call_id=post_request['id']) for post_request in post_requests]}, goto='agent')
else:
return Send(node='tools', arg=state)The tools node
For the tools node we use the prebuilt ToolNode. Here the tool calls are retrieved from the last message in the graphs message history and executed. For every tool call a Tool Message is appended to the message history containing the return value of the tool or an error message on failure.
tool_node = ToolNode(agent_tools)Building the graph
Add nodes and edges
Now we build our graph by adding the nodes and edges. Edges define what nodes to execute next.
workflow = StateGraph(AgentState)
workflow.add_node('agent', agent)
workflow.add_node('tools', tool_node)
workflow.add_node('human_review', human_review)
workflow.add_edge(START, "agent")
workflow.add_edge("tools", "agent")
display(workflow.compile())Add conditional edges
Conditional edges define what node to execute next based on a condition. We add a conditional edge which routes the execution from the agent node either to the review node or the end node depending on whether the language model executed a tool call.
def should_continue(state: AgentState) -> Literal["tools", "__end__"]:
last_message = state["messages"][-1]
if hasattr(last_message, "tool_calls") and len(last_message.tool_calls) > 0:
return "tool call"
return "__end__"
workflow.add_conditional_edges(source="agent", path=should_continue, path_map={"tool call": "human_review", "__end__": END})Compile the graph
Finally, we compile our graph. When compiling we add a checkpointer to achieve thread-level persistence. With a checkpointer specified at compilation, a snapshot of the graph state is saved at every superstep. This is crucial for human-in-the-loop interactions as we need to resume execution after an interrupt is called.
checkpointer = MemorySaver()
timesheet_agent = workflow.compile(checkpointer=checkpointer)
display(timesheet_agent)Invoke the agent
def process_output(stream):
for token in stream:
(key, content), = token.items()
if key == "__interrupt__":
print(content[0])
return True
if content is not None:
content['messages'][-1].pretty_print()
print("\n")
return FalseNow we can stream the output of our agent. We hand over a dictionary containing the user's input and a config with our thread id. Each thread represents an individual session between the graph and the user. If we want to continue the conversation, we need to pass the same thread id to the graph. As you can see we pass a Command object instead of a dictionary the second time we invoke the agent. This is necessary when an interrupt is triggered.
config = {"configurable": {"thread_id": str(uuid.uuid1())}}
user_input = {'messages': ['user', 'Today I worked from 6 to 6 with a half hour break at 12.']}
process_output(timesheet_agent.stream(user_input, config, stream_mode='updates'))
user_input = Command(resume='Sure.')
process_output(timesheet_agent.stream(user_input, config, stream_mode='updates')) ==================================User Input==================================
Today I worked from 6 to 6 with a half hour break at 12.
==================================Ai Message==================================
Name: agent
To log your work time accurately, I will exclude the half-hour break from your total work hours.
Your work hours are:
- From 06:00 to 12:00 (6 hours)
- From 12:30 to 18:00 (5.5 hours)
Total work hours: 11.5 hours
I will now log these work hours for today. Let me first retrieve today's date.
Tool Calls:
get_today (call_rHtgYYzDZ5aTvTwyzf4oCmk1)
Call ID: call_rHtgYYzDZ5aTvTwyzf4oCmk1
Args:
=================================Tool Message=================================
Name: get_today
2025-03-11
==================================Ai Message==================================
Name: agent
Tool Calls:
post_records (call_uHlGJYSAiPqzwWkdBnoFudoh)
Call ID: call_uHlGJYSAiPqzwWkdBnoFudoh
Args:
data: {'startDate': '2025-03-11', 'startTime': 'PT06H00M00S', 'endTime': 'PT12H00M00S'}
confirmation_messages: Would you like to log your work hours from 06:00 to 12:00 on March 11th 2025?
post_records (call_0KBLAkU10fyGTnUxPjRlFSdm)
Call ID: call_0KBLAkU10fyGTnUxPjRlFSdm
Args:
data: {'startDate': '2025-03-11', 'startTime': 'PT12H30M00S', 'endTime': 'PT18H00M00S'}
confirmation_messages: Would you like to log your work hours from 12:30 to 18:00 on March 11th 2025?
Interrupt message: ['Would you like to log your work hours from 06:00 to 12:00 on March 11th 2025?', 'Would you like to log your work hours from 12:30 to 18:00 on March 11th 2025?']
==================================User Input==================================
Sure.
Model explanation: The user has confirmed the action by saying 'Sure.'
=================================Tool Message=================================
Name: post_records
Entity created succesfully.
==================================Ai Message==================================
Name: agent
Your work hours for today, March 11th, 2025, have been successfully logged:
- From 06:00 to 12:00
- From 12:30 to 18:00
If you need any further assistance, feel free to ask!Now you can try interacting with the agent.
config = {"configurable": {"thread_id": str(uuid.uuid1())}}
interrupted = False
print("Type to interact with the agent (type q to quit):\n")
while True:
user_input = input()
if user_input.lower() == 'q':
break
print(user_input)
if interrupted:
interrupted = False
user_input = Command(resume=user_input)
else:
user_input = {'messages': ['user', user_input]}
interrupted = process_output(timesheet_agent.stream(user_input, config, stream_mode="updates")) Type to interact with the agent (type q to quit):
==================================User Input==================================
worked from 10 to 11
==================================Ai Message==================================
Name: agent
Please provide the date on which you worked from 10:00 to 11:00. This will help me log your work time accurately.
==================================User Input==================================
yesterday
==================================Ai Message==================================
Name: agent
Tool Calls:
get_today (call_dtYYuTiZKB6TI8OIhtQSyMbZ)
Call ID: call_dtYYuTiZKB6TI8OIhtQSyMbZ
Args:
=================================Tool Message=================================
Name: get_today
2025-03-11
==================================Ai Message==================================
Name: agent
I will log your work time from 10:00 to 11:00 on 2025-03-10 (yesterday).
Proceeding to post the record.
Tool Calls:
post_records (call_P6bfNYrinCaR4tjLdetMNEjP)
Call ID: call_P6bfNYrinCaR4tjLdetMNEjP
Args:
data: {'startDate': '2025-03-10', 'startTime': 'PT10H00M00S', 'endTime': 'PT11H00M00S'}
confirmation_messages: Would you like to log your work hours from 10:00 to 11:00 on March 10th, 2025?
Interrupt: ['Would you like to log your work hours from 10:00 to 11:00 on March 10th, 2025?']
==================================User Input==================================
sounds good
Model explanation: The user confirmed that the action sounds good.
=================================Tool Message=================================
Name: post_records
Entity created succesfully.
==================================Ai Message==================================
Name: agent
Your work time from 10:00 to 11:00 on March 10th, 2025 has been successfully logged. If you need any further assistance, feel free to ask!Implementing your own agent
Now you have all the knowledge to build your first own agent. But before you start implementing your own llm-based agent, you need to assess whether your specific use case benefits from the capabilities of agentic AI or if a more traditional approach might be more fit. In general, agents unleash their full potential in scenarios where multistep "human-like-reasoning" is required especially when subsequent steps need to be chosen dynamically.
As you might have noticed the use case of a timesheet management agent doesn't fully exhibit these properties. There are other use cases that benefit more from agenticness. Here you can find a video of a use case that highly benefits from agentic AI. This example shows an agent for exploratory data analysis, insight generation and sales forecasting, purely built with the ReAct Agent LangGraph ships out of the box.
Outlook
In this blog we explored building a human-in-the-loop agent for timesheet management using the SAP Generative AI Hub SDK, LangGraph and a SuccessFactors API. By walking through a simple yet customizable example, we demonstrated how approachable and powerful agentic workflows can be. While our demo focused on a straightforward use case, it provides the foundational knowledge necessary for you to expand into more complex, highly agentic use cases such as insight generation, automated customer service or intelligent procurement.
By integrating these tools into your workflow today, you can stay ahead of the curve as llm-based agents will become the cornerstone of business automation.
Now it's your turn!
Appendix
Defining the tools
import urllib.parse
import uuid
from datetime import date, datetime
from typing import TypedDict, Annotated
import requests
from langchain_core.tools import tool, InjectedToolCallIdDefine headers and API endpoint.
os.environ["PAYROLL_API_KEY"] = "<YOUR API KEY>"
API_KEY = os.environ.get("PAYROLL_API_KEY")
get_header = {
'Accept': 'application/json',
'APIKey': API_KEY,
"DataServiceVersion": "2.0"
}
post_header = {
'Content-Type': 'application/json',
'APIKey': API_KEY,
"DataServiceVersion": "2.0"
}
base_url = 'https://sandbox.api.sap.com/successfactors/odata/v2/'
#randomly chosen user_id
user_id = '100257'Now we define a class containing all relevant information to make a post request to the API endpoint. This is the class the LLM will use to pass data to our tool making post requests.
class ExternalTimeData(TypedDict):
startDate: str
startTime: str
endTime: strNext we define our tools. A tool can be any Python function. You should always provide a meaningful docstring as this is what the LLM will use to infer the tools functionality. This first tool retrieves today's date.
@tool
def get_today():
"""Returns today's date."""
return date.today()This next tool retrieves entries of ExternalTimeData for the current user only.
@tool
def get_records(top:int):
"""Retrieve ExternalTimeData entries from the API for the current user.
top: Specifies the number of entries to retrieve.
"""
params = {}
if top is not None:
params['$top'] = top
params['$filter'] = f'userId eq {user_id}'
query_text = urllib.parse.urlencode(params, safe='(),')
table = 'ExternalTimeData'
url = f'{base_url}{table}?{query_text}'
response = requests.get(url, headers=get_header)
if response.status_code == 200:
data = response.json()
return data
else:
return f'Error: {response.content}'Our last tool posts entries to ExternalTimeData. While these tools are rather simple, tools can be arbitrarily complex. In general, it's a good idea to only bind a handful of tools to one LLM. If you need more tools you should consider a multi-agent architecture or implement a tool selection based on the user's messages using embedding for instance.
@tool
def post_records(data: ExternalTimeData, confirmation_message : str):
"""Post work time records to the API.
The user is first asked for confirmation.
Changes by the user to the times to post are returned in the ToolMessage.
Args:
data:
startDate: YYYY-mm-dd
startTime/endTime: ISO 8601 e.g. 'PT09H00M00S'/'PT18H30M00S' (9:00-18:30). The time frame must only include work time.
confirmation_message: Confirmation message of the action containing a detailed summary.
Include the specific date and time e.g. Confirm work time from 11:00 to 14:00 on June 15th 2025.
"""
payload = dict(data)
payload['startDate'] = f"/Date({int(datetime.fromisoformat(data['startDate']).timestamp()) * 1000})/"
payload['externalCode'] = str(uuid.uuid1())
payload['userId'] = user_id
payload["userIdNav"] = {
"__metadata": {
"uri": f"https://sandbox.api.sap.com/successfactors/odata/v2/User('{user_id}')"
}
}
table = 'ExternalTimeData'
url = f'{base_url}{table}'
response = requests.post(url, headers=post_header, json=payload)
if response.status_code in (200, 201):
return f'Entity created successfully: {data}'
else:
return f'Error creating entity ({data}): {response.status_code}: {response.content}'Implementing the frontend
The frontend is implemented using the opensource framework Streamlit. The code can be found in the Git repository. Navigate to the folder containing the full example code and run the following command to start the application:
streamlit run frontend.pyFor the implementation we will adjust the implementation of the review node. Similar to before, an interrupt is triggered when the agent node tries to make a post request. When this happens the user must provide explicit feedback for every action. We change the last message in the graph's message history to include all tool calls not directly approved and append ToolMessages accordingly. Lastly, we append a message with all approved action. This ensures a consistent message history as well as ensuring only approved actions are performed. The options the user can choose from are the following:
- Approve: The request is routed to the tool node. The required ToolMessage is automatically created by the ToolNode.
- Deny: Before routing the request to the ToolNode, we append a ToolMessage.
- Make Changes: Similar to the previous option, a ToolMessage is appended including additional information on changes to the action.
def human_review(state: AgentState):
last_message = state["messages"][-1]
post_requests = np.array(list(filter(lambda x: x['name'] == 'post_records', last_message.tool_calls)))
if len(post_requests) > 0:
confirmation_messages = list(map(lambda x: x['args']['confirmation_message'], post_requests))
user_review = interrupt({"task": "Review the action.",
"action": confirmation_messages})
user_review = json.loads(user_review)
selections = np.array(user_review['selections'])
user_changes = user_review['user_changes']
approved = post_requests[selections == 0]
approved_message = AIMessage(content=last_message.content, tool_calls=approved.tolist())
not_approved = post_requests[selections != 0]
not_approved_message = AIMessage(content=last_message.content, id=last_message.id, tool_calls=not_approved.tolist())
denied = post_requests[selections == 1]
to_change = post_requests[selections == 2]
denied_messages = [ToolMessage(f"Tool call was not executed (denied by user): "
f"{row['args']['confirmation_message']}."
f"Do not post the same record again.",
tool_call_id=row['id'])
for row in denied]
to_change_messages = [ToolMessage(f"Tool call was not executed (user wants to make changes)."
f"To this message: "
f"<{row['args']['confirmation_message']}> "
f"the user responded: "
f"<{user_changes}>"
f"Incorporate this request and call the tool again. "
f"Do not call the tool with the same values.",
tool_call_id=row['id'])
for row in to_change]
tool_messages = denied_messages + to_change_messages
return Command(update={
"messages": [not_approved_message] + tool_messages + [approved_message]}, goto='tools')
else:
return Send(node='tools', arg=state)Deploy the application with Cloud Foundry
In order to roll out the application you need to:
- Clone the Git repository
- Enter your credentials in manifest.yml
- Configure the Cloud Foundry CLI
After following these steps you can push the app with:
cf push
Thanks to @AndreasForster for his feedback and help! You can find a blog he wrote on implementing an invoice agent here.
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.