
Sap Agent Bootstrap
- 1 installs
- Updated May 5, 2026
- ericsolberg/returned-goods-exception-manager
Bootstrap a deployable SAP App Foundation AI agent project with A2A protocol, LangGraph, SAP AI Core, and GitHub Actions CI/CD scaffolding.
About
Scaffolds a ready-to-deploy SAP App Foundation agent by collecting a name and description, copying templates, and substituting placeholders into the project structure. A developer uses it only within the prd-to-spec or spec-to-code generation chain, never standalone.
- Deterministic template copy plus placeholder derivation from two inputs
- Documents deployment gotchas like config ordering and lazy async MCP tool loading
Sap Agent Bootstrap by the numbers
- 1 all-time installs (skills.sh)
- Ranked #14,102 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 8, 2026 (Skillselion catalog sync)
npx skills add https://github.com/ericsolberg/returned-goods-exception-manager --skill sap-agent-bootstrapAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| Last updated | May 5, 2026 |
| Repository | ericsolberg/returned-goods-exception-manager ↗ |
What it does
Bootstrap a deployable SAP App Foundation AI agent project with A2A protocol, LangGraph, SAP AI Core, and GitHub Actions CI/CD scaffolding.
Files
App Foundation Agent Bootstrap
Creates a ready-to-deploy AI agent asset for SAP App Foundation with A2A protocol, LangGraph, SAP AI Core integration, and GitHub Actions CI/CD.
This skill operates on the current working directory. The caller is responsible for running it from the correct target directory (e.g. assets/<asset-name>/).
Instructions
Follow these 3 phases in order:
Phase 1: Collect User Input
Use question tool if available or a similar tool that can be used to ask questions to the user to gather exactly 2 values BEFORE any file operations:
Question 1: "Please enter your agent name (e.g., expense-tracker-agent):"
Question 2: "Please enter your agent description (e.g., 'An AI agent that tracks business expenses'):"Example interaction:
- User wants: "Create an agent to help with travel expenses"
- Agent name:
travel-expense-agent - Agent description:
An AI agent that helps employees manage and submit travel expenses
Phase 2: Copy Templates (Deterministic)
Run the appropriate shell command based on user's OS:
macOS/Linux:
mkdir -p app .github/workflows
# Search upward from the current directory to find the skill (it lives in .claude/skills/ above assets/agent/)
SEARCH_DIR="."
SKILL_PATH=""
while [ "$(realpath "$SEARCH_DIR")" != "/" ]; do
SKILL_PATH=$(find "$SEARCH_DIR" -maxdepth 4 -type d -name "sap-agent-bootstrap" -path "*/skills/*" 2>/dev/null | head -1)
[ -n "$SKILL_PATH" ] && break
SEARCH_DIR="$SEARCH_DIR/.."
done
if [ -z "$SKILL_PATH" ]; then echo "ERROR: sap-agent-bootstrap skill not found"; exit 1; fi
cp -r "$SKILL_PATH/templates/app/." ./app/
for file in ./app/*.py.template; do [ -f "$file" ] && mv "$file" "${file%.template}"; done
cp "$SKILL_PATH/templates/Dockerfile.template" ./Dockerfile
cp "$SKILL_PATH/templates/requirements.txt.template" ./requirements.txt
cp "$SKILL_PATH/templates/README.md" ./
cp "$SKILL_PATH/templates/.gitignore" ./
cp -r "$SKILL_PATH/templates/.github/." ./.github/Windows PowerShell:
New-Item -ItemType Directory -Force -Path app, .github/workflows
# Search upward from current directory to find the skill
$SearchDir = Get-Location
$SkillPath = $null
while ($SearchDir -ne $SearchDir.Parent -and $null -eq $SkillPath) {
$SkillPath = Get-ChildItem -Path $SearchDir -Depth 4 -Recurse -Directory -Filter "sap-agent-bootstrap" -ErrorAction SilentlyContinue | Where-Object { $_.FullName -like "*skills*" } | Select-Object -First 1 -ExpandProperty FullName
$SearchDir = $SearchDir.Parent
}
if (-not $SkillPath) { Write-Error "ERROR: sap-agent-bootstrap skill not found"; exit 1 }
Get-ChildItem "$SkillPath/templates/app" -Force | Copy-Item -Destination "./app/" -Recurse -Force
Get-ChildItem -Path "./app/*.py.template" | ForEach-Object { Rename-Item -Path $_.FullName -NewName ($_.Name -replace '\.template$', '') }
Copy-Item "$SkillPath/templates/Dockerfile.template" -Destination "./Dockerfile"
Copy-Item "$SkillPath/templates/requirements.txt.template" -Destination "./requirements.txt"
Copy-Item "$SkillPath/templates/README.md" -Destination "./"
Copy-Item "$SkillPath/templates/.gitignore" -Destination "./"
Get-ChildItem "$SkillPath/templates/.github" -Force | Copy-Item -Destination "./.github/" -Recurse -ForcePhase 3: Replace Placeholders (Deterministic)
Use shell commands to replace all placeholders. Derive values from the 2 inputs collected in Phase 1. Refer to "Placeholder Derivation Rules" section for more information
macOS (sed -i ''):
# Replace in README.md
sed -i '' 's/{{AGENT_TITLE}}/<Agent Title>/g' README.md
sed -i '' 's/{{AGENT_DESCRIPTION}}/<agent-description>/g' README.md
# Replace in app/main.py
sed -i '' 's/{{AGENT_ID}}/<agent-name>/g' app/main.py
sed -i '' 's/{{AGENT_NAME}}/<agent-name>/g' app/main.py
sed -i '' 's/{{AGENT_SKILL_DESCRIPTION}}/<agent-description>/g' app/main.py
sed -i '' 's/{{AGENT_CARD_DESCRIPTION}}/<agent-description>/g' app/main.py
sed -i '' 's/{{AGENT_TAGS}}/<tags-list>/g' app/main.py
sed -i '' 's/{{AGENT_EXAMPLES}}/<examples-list>/g' app/main.py
# Replace in app/agent.py
sed -i '' 's/{{SYSTEM_PROMPT}}/<system-prompt>/g' app/agent.pyLinux (sed -i without quotes):
sed -i 's/{{AGENT_TITLE}}/<Agent Title>/g' README.md
# ... same pattern as macOS but targeting app/Windows PowerShell:
# Replace in README.md
(Get-Content README.md) -replace '{{AGENT_TITLE}}','<Agent Title>' | Set-Content README.md
(Get-Content README.md) -replace '{{AGENT_DESCRIPTION}}','<agent-description>' | Set-Content README.md
# Replace in app/main.py
(Get-Content app/main.py) -replace '{{AGENT_ID}}','<agent-name>' | Set-Content app/main.py
(Get-Content app/main.py) -replace '{{AGENT_NAME}}','<agent-name>' | Set-Content app/main.py
(Get-Content app/main.py) -replace '{{AGENT_SKILL_DESCRIPTION}}','<agent-description>' | Set-Content app/main.py
(Get-Content app/main.py) -replace '{{AGENT_CARD_DESCRIPTION}}','<agent-description>' | Set-Content app/main.py
(Get-Content app/main.py) -replace '{{AGENT_TAGS}}','<tags-list>' | Set-Content app/main.py
(Get-Content app/main.py) -replace '{{AGENT_EXAMPLES}}','<examples-list>' | Set-Content app/main.py
# Replace in app/agent.py
(Get-Content app/agent.py) -replace '{{SYSTEM_PROMPT}}','<system-prompt>' | Set-Content app/agent.pyPlaceholder Derivation Rules
Derive all 10 placeholders from the 2 user inputs:
| Placeholder | Derivation | Example Value |
|---|---|---|
{{AGENT_NAME}} | Direct from input | travel-expense-agent |
{{AGENT_NAMESPACE}} | Same as AGENT_NAME | travel-expense-agent |
{{AGENT_ID}} | Same as AGENT_NAME | travel-expense-agent |
{{AGENT_TITLE}} | Title-case: replace - with space, capitalize | Travel Expense Agent |
{{AGENT_TAGS}} | Split AGENT_NAME by - into Python list | ["travel", "expense", "agent"] |
{{AGENT_DESCRIPTION}} | Direct from input | An AI agent that helps employees manage and submit travel expenses |
{{AGENT_SKILL_DESCRIPTION}} | Same as AGENT_DESCRIPTION | An AI agent that helps employees manage and submit travel expenses |
{{AGENT_CARD_DESCRIPTION}} | Same as AGENT_DESCRIPTION | An AI agent that helps employees manage and submit travel expenses |
{{SYSTEM_PROMPT}} | Template: You are {AGENT_DESCRIPTION}. Help users with their requests. | You are an AI agent that helps employees manage and submit travel expenses. Help users with their requests. |
{{AGENT_EXAMPLES}} | Generate 2 example prompts based on description | ["Help me submit a travel expense", "What are the expense policies?"] |
Output Structure
The skill produces the following layout inside the current working directory (e.g. assets/<asset-name>/):
assets/<asset-name>/
├── .github/workflows/dev-ci-cd.yml
├── .gitignore
├── README.md
├── Dockerfile
├── requirements.txt
└── app/
├── __init__.py
├── main.py
├── agent_executor.py
└── agent.pyNote: asset.yaml and solution.yaml are NOT created by this skill. They are created later by the setup-solution skill, which runs at the end of the full workflow.
Optional: When using pydantic package in your agent code
Add pydantic to requirements.txt file, but don't add a package version to avoid conflicts with SAP AI Core's pydantic version.
Customization
- Tools: Extend LangGraph in
agent.py - Skills: Add
AgentSkilldefinitions inmain.py
⚠️ Important: Dependencies
Note: Dependencies listed in requirements.txt are NOT installed during the bootstrap process. They will be installed:
- Locally: When you run the agent using the
sap-agent-run-localskill - In the cluster: Automatically during the deployment process via CI/CD pipeline
The bootstrap process only creates the project structure and configuration files. No local Python environment setup is performed at this stage.
⚠️ Known Deployment Gotchas
These issues have caused real deployment failures and are proven to break the agent on the platform:
1. `set_aicore_config()` and `auto_instrument()` must be first — these must be called at the very top of main.py, before any AI framework imports (LangChain, LiteLLM, etc.). The platform SDK hooks into the import process; importing AI frameworks first causes telemetry to be missed or misconfigured.
2. MCP tool loading via `MultiServerMCPClient` must be async and lazy — get_tools() is async and makes real network calls to MCP servers. It cannot be called from __init__() and cannot be made sync. Additionally, async with MultiServerMCPClient(...) raises NotImplementedError — do not use it as a context manager. The correct pattern is:
async def _load_mcp_tools():
client = MultiServerMCPClient({...})
return await client.get_tools()
async def _get_graph(self):
if self._graph is None:
tools = await _load_mcp_tools()
self._graph = create_react_agent(self.llm, tools=tools)
return self._graphIf MCP tools are loaded in __init__(), the HTTP server cannot start before the startup probe fires, causing the container to be killed.
Next Steps
After bootstrapping completes, return control to the calling skill to continue implementation. Do not prompt the user with interactive options — this skill is only invoked as part of the automated prd-to-spec → spec-to-code chain.
name: App Foundation CD deploy to CONHOS
on:
workflow_dispatch:
pull_request:
push:
branches:
- "main"
jobs:
deploy-conhos:
uses: application-foundation/ci-cd-workflow/.github/workflows/deploy-conhos.yml@main
secrets: inherit
permissions:
contents: write
id-token: write# App Foundation Agent
import logging
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.server.tasks import TaskUpdater
from a2a.types import (
InternalError,
Part,
TaskState,
TextPart,
UnsupportedOperationError,
)
from a2a.utils import new_agent_text_message, new_task
from a2a.utils.errors import ServerError
from agent import SampleAgent
logger = logging.getLogger(__name__)
class AgentExecutor(AgentExecutor):
def __init__(self):
self.agent = SampleAgent()
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
query = context.get_user_input()
task = context.current_task
if not task:
task = new_task(context.message)
await event_queue.enqueue_event(task)
updater = TaskUpdater(event_queue, task.id, task.context_id)
try:
async for item in self.agent.stream(query, task.context_id):
if not item["is_task_complete"] and not item["require_user_input"]:
await updater.update_status(
TaskState.working,
new_agent_text_message(
item["content"], task.context_id, task.id
),
)
elif item["require_user_input"]:
await updater.update_status(
TaskState.input_required,
new_agent_text_message(
item["content"], task.context_id, task.id
),
final=True,
)
break
else:
await updater.add_artifact(
[Part(root=TextPart(text=item["content"]))], name="agent_result"
)
await updater.complete()
break
except Exception as e:
logger.exception("Agent execution error")
raise ServerError(error=InternalError()) from e
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
raise ServerError(error=UnsupportedOperationError())
import logging
from dataclasses import dataclass
from typing import AsyncGenerator, Literal
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_litellm import ChatLiteLLM
from langgraph.graph import START, MessagesState, StateGraph
from sap_cloud_sdk.agent_decorators import agent_model, prompt_section
logger = logging.getLogger(__name__)
@agent_model(
key="config.model",
label="LLM Model",
description="The language model powering this agent",
)
def get_model_name() -> str:
return "sap/anthropic--claude-4.5-sonnet"
@prompt_section(
key="prompts.system",
label="System Prompt",
description="The full system prompt defining the agent's role and behavior",
validation={"format": "markdown", "max_length": 5000},
)
def get_system_prompt() -> str:
return """{{SYSTEM_PROMPT}}"""
@dataclass
class AgentResponse:
status: Literal["input_required", "completed", "error"]
message: str
class SampleAgent:
SUPPORTED_CONTENT_TYPES = ["text", "text/plain"]
def __init__(self):
self.llm = ChatLiteLLM(model=get_model_name())
self.graph = self._build_graph()
def _build_graph(self):
async def call_model(state: MessagesState):
response = await self.llm.ainvoke(state["messages"])
return {"messages": [response]}
builder = StateGraph(MessagesState)
builder.add_node("model", call_model)
builder.add_edge(START, "model")
return builder.compile()
async def stream(self, query: str, context_id: str) -> AsyncGenerator[dict, None]:
yield {
"is_task_complete": False,
"require_user_input": False,
"content": "Processing...",
}
try:
messages = [
SystemMessage(content=get_system_prompt()),
HumanMessage(content=query),
]
result = await self.graph.ainvoke({"messages": messages})
response = result["messages"][-1].content
yield {
"is_task_complete": True,
"require_user_input": False,
"content": response,
}
except Exception as e:
yield {
"is_task_complete": True,
"require_user_input": False,
"content": f"Error: {e}",
}
def invoke(self, query: str, context_id: str) -> AgentResponse:
import asyncio
try:
messages = [
SystemMessage(content=get_system_prompt()),
HumanMessage(content=query),
]
result = asyncio.run(self.graph.ainvoke({"messages": messages}))
response = result["messages"][-1].content
return AgentResponse(status="completed", message=response)
except Exception as e:
return AgentResponse(status="error", message=f"Error: {e}")
# CRITICAL: Initialize telemetry BEFORE importing AI frameworks
from sap_cloud_sdk.aicore import set_aicore_config
from sap_cloud_sdk.core.telemetry import auto_instrument
set_aicore_config()
auto_instrument()
import logging
import os
import click
import uvicorn
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.tasks import InMemoryTaskStore
from a2a.types import AgentCapabilities, AgentCard, AgentSkill
from agent_executor import AgentExecutor
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
HOST = os.environ.get("HOST", "0.0.0.0")
PORT = int(os.environ.get("PORT", "5000"))
@click.command()
@click.option("--host", default=HOST)
@click.option("--port", default=PORT)
def main(host: str, port: int):
skill = AgentSkill(
id="{{AGENT_ID}}",
name="{{AGENT_NAME}}",
description="{{AGENT_SKILL_DESCRIPTION}}",
tags={{AGENT_TAGS}},
examples={{AGENT_EXAMPLES}},
)
agent_card = AgentCard(
name="{{AGENT_NAME}}",
description="{{AGENT_CARD_DESCRIPTION}}",
url=os.environ.get("AGENT_PUBLIC_URL", f"http://{host}:{port}/"),
version="1.0.0",
defaultInputModes=["text", "text/plain"],
defaultOutputModes=["text", "text/plain"],
capabilities=AgentCapabilities(streaming=True, pushNotifications=False),
skills=[skill],
)
server = A2AStarletteApplication(
agent_card=agent_card,
http_handler=DefaultRequestHandler(
agent_executor=AgentExecutor(),
task_store=InMemoryTaskStore(),
),
)
logger.info(f"Starting A2A server at http://{host}:{port}")
uvicorn.run(server.build(), host=host, port=port)
if __name__ == "__main__":
main()
FROM docker-hub.common.cdn.repositories.cloud.sap/python:3.13-slim
ARG ARTIFACTORY_USER
ARG ARTIFACTORY_TOKEN
ENV ARTIFACTORY_URL="https://common.repositories.cloud.sap/artifactory/api/pypi/application-foundation-sdk-python"
ENV ARTIFACTORY_USER=${ARTIFACTORY_USER}
ENV ARTIFACTORY_TOKEN=${ARTIFACTORY_TOKEN}
WORKDIR /app
COPY requirements.txt .
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* && \
pip install --no-cache-dir --upgrade pip && \
mkdir -p /root/.pip && \
printf "[global]\n\
index-url = https://%s:%s@common.repositories.cloud.sap/artifactory/api/pypi/application-foundation-sdk-python/simple\n\
extra-index-url = https://pypi.org/simple\n" \
"$ARTIFACTORY_USER" "$ARTIFACTORY_TOKEN" > /root/.pip/pip.conf && \
pip install --no-cache-dir -r requirements.txt && \
rm -rf /root/.pip
COPY app/ ./app/
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
LITELLM_LOCAL_MODEL_COST_MAP=True \
LITELLM_TELEMETRY=False
EXPOSE 5000
CMD ["python", "app/main.py", "--host", "0.0.0.0", "--port", "5000"]{{AGENT_TITLE}}
{{AGENT_DESCRIPTION}}
Overview
Uses A2A Protocol, LangGraph, LiteLLM, and Application Foundation SDK.
Structure
Dockerfile- Container buildapp/main.py- A2A server entryapp/agent_executor.py- Request handlingapp/agent.py- Agent logic
Local Development
Requires SAP Artifactory credentials. Use appfnd-agent-run-local skill for instructions.
litellm==1.81.0,!=1.82.7,!=1.82.8
langchain==1.2.6
langchain-core==1.2.28
langchain-litellm==0.3.5
langgraph==1.0.10
a2a-sdk[all]==0.3.22
uvicorn==0.40.0
httpx==0.28.1
python-dotenv==1.2.1
click==8.3.1
sap-cloud-sdk==0.6.1.dev20260410+contribs-sapphire-internal-testing.42f21d5