Your investigator crew is working great locally. But right now, it only runs on your machine. In this exercise, you'll expose it as a web service that anyone (or any other agent) can call remotely, and deploy it to SAP BTP Cloud Foundry.
To do that, you'll use the A2A protocol (Agent-to-Agent), an open standard that lets AI agents communicate with each other over HTTP — regardless of which framework or platform they were built on.
By the end of this exercise, your investigator crew will be:
- ✅ Running as a persistent HTTP server
- ✅ Reachable via a public URL on SAP BTP
- ✅ Discoverable by other agents through the A2A standard
A2A (Agent-to-Agent) is an open protocol, originally developed by Google, that standardizes how AI agents communicate with each other over HTTP. Think of it as REST for agents.
| Concept | What it is | Example |
|---|---|---|
| Agent Card | A JSON document describing what an agent can do | "I can investigate art thefts" |
| Skill | A specific capability of an agent | investigate skill |
| Task | A unit of work sent to the agent | "Find the suspect" |
| Event Queue | Stream of status updates while the agent works | working → completed |
Without A2A, each agent framework speaks its own language. With A2A:
| Without A2A | With A2A |
|---|---|
| ❌ Agents are locked into one framework | ✅ Any agent can call any other agent |
| ❌ Custom integration code per tool/agent | ✅ Standard HTTP endpoints, discoverable by URL |
| ❌ No standard way to describe capabilities | ✅ Agent Card at /.well-known/agent-card.json |
| ❌ No standard way to report progress | ✅ Event-based task status updates (working → completed) |
👉 Create a new file /project/Python/starter-project/server.py.
import asyncio
import json
import os
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.apps.jsonrpc import A2AFastAPIApplication
from a2a.server.events import EventQueue
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.tasks import InMemoryTaskStore
from a2a.types import (
Artifact,
TaskState,
TaskStatus,
TaskStatusUpdateEvent,
TaskArtifactUpdateEvent,
TextPart,
AgentCard,
AgentCapabilities,
AgentSkill,
)
from fastapi.middleware.cors import CORSMiddleware
from investigator_crew import InvestigatorCrew
from payload import payload💡 What these imports do:
AgentExecutor— Abstract base class you must implement. It definesexecute()andcancel(), the two lifecycle methods of a task.RequestContext— Carries the incoming task: the message from the caller, the task ID, and the context ID.EventQueue— You push events into this queue to report progress back to the caller (working,completed,canceled).A2AFastAPIApplication— Wires the A2A protocol on top of FastAPI. Handles routing, JSON-RPC encoding, and the Agent Card endpoint automatically.InMemoryTaskStore— Stores task state in memory. Sufficient for a single-instance deployment.AgentCard,AgentSkill,AgentCapabilities— The self-description of your agent, served at/.well-known/agent-card.json.
This is the heart of the server — the class that actually runs your crew when a task arrives.
class InvestigatorExecutor(AgentExecutor):
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
# 1. Tell the caller we've started working
await event_queue.enqueue_event(
TaskStatusUpdateEvent(
task_id=context.task_id,
context_id=context.context_id,
status=TaskStatus(state=TaskState.working),
final=False,
)
)
# 2. Parse the incoming message
user_input = context.get_user_input()
try:
parsed = json.loads(user_input)
user_request = parsed.get("user_request", user_input)
suspect_names = parsed.get("suspect_names", user_input)
except (json.JSONDecodeError, TypeError):
user_request = user_input
suspect_names = user_input
# 3. Run the crew (blocking call, so we offload it to a thread)
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
lambda: InvestigatorCrew().crew().kickoff(
inputs={
"payload": payload,
"user_request": user_request,
"suspect_names": suspect_names,
}
),
)
# 4. Send the result back as an artifact
await event_queue.enqueue_event(
TaskArtifactUpdateEvent(
task_id=context.task_id,
context_id=context.context_id,
artifact=Artifact(
artifactId="investigation_result",
parts=[TextPart(text=str(result))],
name="investigation_result",
),
)
)
# 5. Mark the task as completed
await event_queue.enqueue_event(
TaskStatusUpdateEvent(
task_id=context.task_id,
context_id=context.context_id,
status=TaskStatus(state=TaskState.completed),
final=True,
)
)
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
await event_queue.enqueue_event(
TaskStatusUpdateEvent(
task_id=context.task_id,
context_id=context.context_id,
status=TaskStatus(state=TaskState.canceled),
final=True,
)
)💡 Understanding the executor step by step:
Step 1 — Signal
workingThe first thing you do is tell the caller the task has been received and is in progress.final=Falsemeans more events will follow.Step 2 — Parse the input The caller sends plain text or JSON. We try to parse it as JSON so we can extract structured fields like
suspect_names. If it's not JSON, we use the raw string.Step 3 — Run the crew in a thread
InvestigatorCrew().crew().kickoff()is a synchronous, blocking call — CrewAI is not async-native. Calling it directly inside anasyncfunction would freeze the entire server.run_in_executormoves it to a thread pool, keeping the event loop free to handle other requests.Step 4 — Return the result as an artifact An
Artifactis the A2A way of returning a result. It has an ID, a name, and a list ofparts. We useTextPartsince the crew returns a markdown string.Step 5 — Signal
completedfinal=Truecloses the task. The caller knows it can stop waiting.
This snippet detects whether the app is running on Cloud Foundry or locally, and sets app_url accordingly.
app_url = (
lambda d: f"https://{d.get('application_uris', [])[0]}"
if d.get("application_uris")
else None
)(json.loads(os.environ.get("VCAP_APPLICATION", "{}")))
if not app_url: app_url = "http://localhost:8080"💡 How URL detection works:
Cloud Foundry injects a
VCAP_APPLICATIONenvironment variable containing a JSON object with metadata about the running app, includingapplication_uris— the list of public routes assigned to it. When that variable is present, the first URI is used to build thehttps://URL. When running locally,VCAP_APPLICATIONis absent, soapp_urlfalls back tohttp://localhost:8080.
agent_card = AgentCard(
name="Investigator Crew",
description="Multi-agent art theft investigation crew exposed as an A2A server",
url=app_url,
version="1.0.0",
capabilities=AgentCapabilities(streaming=False),
skills=[
AgentSkill(
id="investigate",
name="Investigate Art Theft",
description="Investigates art theft cases by appraising losses and analyzing evidence",
tags=["investigation", "art", "insurance", "theft"],
inputModes=["text/plain"],
outputModes=["text/markdown"],
)
],
defaultInputModes=["text/plain"],
defaultOutputModes=["text/markdown"],
)
handler = DefaultRequestHandler(
agent_executor=InvestigatorExecutor(),
task_store=InMemoryTaskStore(),
)
app = A2AFastAPIApplication(agent_card=agent_card, http_handler=handler).build()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
def health():
return {"status": "ok"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 8080)))💡 Understanding the Agent Card:
The
AgentCardis the agent's public identity. When another agent or client callsGET /.well-known/agent-card.jsonon your server, it receives this document. It describes:
- What the agent is (
name,description,version)- Where it lives (
url— automatically set fromVCAP_APPLICATIONwhen deployed to CF, falls back tolocalhostotherwise)- What it can do (
skills) — each skill has an ID, description, and declared input/output formats- Whether it streams (
capabilities.streaming=False) — we return results all at once, not as a streamThe
/healthendpoint is required by Cloud Foundry to verify the app started successfully. CF polls it after deployment — if it doesn't return200 OK, the deployment fails.
Your current requirements.txt doesn't include the A2A server libraries. You need to add them.
👉 Open /project/Python/starter-project/requirements.txt and make sure it contains the following:
# Core CrewAI agent framework with A2A protocol support
crewai[a2a]
# A2A SDK with HTTP server support
a2a-sdk[http-server]
# LLM interaction with SAP Generative AI Hub
litellm==1.82.6
# Environment configuration
python-dotenv
# Data validation
pydantic
# HTTP client
httpx
# HTTP requests
requests
# YAML configuration files
PyYAML
# SAP AI Core SDK for integration with SAP Generative AI Hub
sap-ai-sdk-base==3.4.0
sap-ai-sdk-core==3.3.0
sap-ai-sdk-gen==6.7.0
Cloud Foundry uses a manifest.yml file to know how to run your application. Think of it as a recipe: it tells CF how much memory to allocate, which buildpack to use, what command to start the app with, and which services to bind.
👉 Create a new file /project/Python/starter-project/manifest.yml at the root of your starter project.
💡 The
namefield below is a placeholder — thecf pushcommand will override it automatically using your BAS login.
applications:
- name: investigator-crew-<YOUR NAME>
memory: 1024M
disk_quota: 2048M
instances: 1
buildpacks:
- https://github.com/cloudfoundry/python-buildpack/releases/download/v1.8.43/python-buildpack-cflinuxfs4-v1.8.43.zip
health-check-type: http
health-check-http-endpoint: /health
timeout: 180
command: python -m uvicorn server:app --host 0.0.0.0 --port $PORT --workers 1
services:
- generative-ai-hub
env:
LITELLM_PROVIDER: sap
AICORE_RESOURCE_GROUP: ai-agents-codejam
RPT1_DEPLOYMENT_URL: <YOUR_RPT1_DEPLOYMENT_URL>
CREWAI_TRACING_ENABLED: "false"
BP_PYTHON_VERSION: "3.13.11"
⚠️ You must replace the placeholder value:
RPT1_DEPLOYMENT_URL— The same deployment URL you used in Exercise 03. Copy it from your local.envfile.
💡 Understanding each field:
Field Purpose nameThe app name in CF. Your URL will be based on this. memoryRAM allocated per instance. 1GB is enough for uvicorn + CrewAI. disk_quotaDisk space for the app and its dependencies. 2GB covers all Python packages. instancesNumber of app instances. Keep at 1 for this exercise. buildpacksCF uses this to detect Python and install dependencies from requirements.txt. We pin a specific version to ensure reproducibility.health-check-type: httpCF checks the /healthendpoint after startup to confirm the app is ready.health-check-http-endpointThe path CF polls. Must return 200 OK.timeoutHow many seconds CF waits for the health check to pass before failing the deployment. 180s gives the app time to install packages and load models. commandThe startup command. $PORTis injected by CF — your app must listen on this port.servicesCF service instances to bind. generative-ai-hubinjects SAP AI Core credentials as environment variables automatically.
💡 Why
--workers 1? CrewAI is CPU and memory intensive — each worker would be capable of running a full crew execution in parallel. With limited memory (1024M), multiple concurrent crew runs would exhaust available RAM. One worker keeps resource usage predictable and safe for this deployment.
CF's Python buildpack reads runtime.txt to know which Python version to install.
👉 Create a new file /project/Python/starter-project/runtime.txt and add the following line to it:
python-3.13.x
💡 Note: The
xis a wildcard — CF picks the latest patch version of Python 3.13.
Your local .env file contains API keys and credentials. You must not push it to CF — the credentials come from the generative-ai-hub service binding instead.
👉 Create a new file /project/Python/starter-project/.cfignore:
.env
.venv/
__pycache__/
*.pyc
*.pyo
.python-version
⚠️ Important:.cfignoreworks like.gitignorebut forcf push. Files listed here are excluded from the upload to CF. Always include.envhere to prevent accidentally uploading credentials.
👉 Open a terminal and log in to your SAP BTP CF environment:
cf login -a https://api.cf.eu10-004.hana.ondemand.com --origin a7rg4vxjp-platform👉 Use the credentials provided in the system access email.
Email: cd-agents-###
Password: *******
The
--originflag ensures CF redirects you to the correct custom identity provider for this CodeJam.
👉 Select the correct org and space when prompted.
👉 Navigate to your starter-project folder in the terminal:
cd project/Python/starter-project👉 Push the app with a single command that automatically derives your app name from your BAS login:
# BAS / macOS / Linux (bash)
cf push "investigator-crew-$(echo "$USER_NAME" | cut -d '@' -f 1 | tr -d '.')"# Windows (PowerShell) — if running cf locally without BAS
cf push "investigator-crew-yourname"💡 What this command does:
$USER_NAMEis an environment variable automatically set by SAP Business Application Studio to your login email (e.g.nora.von.thenen@sap.com). The shell expression strips the domain (cut -d '@' -f 1) and removes any dots (tr -d '.'), producing a clean app name likeinvestigator-crew-noravonthenen. This overrides thenamefield inmanifest.ymlso you don't have to edit the file manually.On Windows without BAS,
$USER_NAMEis not available — just replaceyournamewith your own identifier.
CF will:
- Upload your project files (excluding anything in
.cfignore) - Detect Python and install dependencies from
requirements.txt - Start the app with the
commandfrommanifest.yml - Poll
/healthuntil it returns200 OK
⚠️ The first push can take a few minutes — CF is downloading and installing all Python packages. Subsequent pushes are faster.
Once the push succeeds, CF prints the app URL:
name: investigator-crew-<YOUR NAME>
requested state: started
routes: investigator-crew-<YOUR NAME>-<to be determined>.cfapps.eu10-004.hana.ondemand.com
👉 Open a browser or run:
curl https://<YOUR_APP_URL>/.well-known/agent-card.jsonYou should see your agent's description:
{
"name": "Investigator Crew",
"description": "Multi-agent art theft investigation crew exposed as an A2A server",
"url": "https://investigator-crew-<YOUR NAME>-<random>.cfapps.eu10-004.hana.ondemand.com",
"version": "1.0.0",
"skills": [...]
}💡 The Agent Card is served at
/.well-known/agent-card.json.
curl https://<YOUR_APP_URL>/healthExpected response: {"status": "ok"}
👉 Open the A2A Editor
👉 Add your agent by pasting the URL: https://<YOUR_APP_URL>/.well-known/agent-card.json
👉 Open the Chat and paste:
{
"user_request": "Investigate the art theft at the museum",
"suspect_names": "Sophie Dubois, Marcus Chen, Viktor Petrov"
}If something went wrong during startup:
cf logs investigator-crew-<YOUR NAME> --recentYou now have a live, publicly reachable multi-agent system:
flowchart TD
Internet --> CFRouter["CF Router"]
CFRouter --> App["investigator-crew-YOUR-NAME\nuvicorn / FastAPI"]
App --> EP1["GET /.well-known/agent-card.json → AgentCard"]
App --> EP2["GET /.well-known/agent.json → AgentCard (backwards compat)"]
App --> EP3["GET /health → {status: ok}"]
App --> EP4["POST / → A2A JSON-RPC handler"]
EP4 --> Executor["InvestigatorExecutor"]
Executor --> Crew["InvestigatorCrew"]
Crew --> A1["Appraiser Agent (RPT-1)"]
Crew --> A2["Evidence Analyst (Grounding)"]
Crew --> A3["Lead Detective (GPT-4o)"]
| CF Feature | What it does for you |
|---|---|
| Buildpack | Detects Python, installs requirements.txt, sets up the runtime |
| Service Binding | Injects AICORE_* credentials into the app environment automatically |
| Health Check | Restarts the app if /health stops responding |
| Router | Terminates TLS and routes HTTPS traffic to your app on $PORT |
| Env vars | Available at runtime via os.environ.get(...) — no .env file needed |
- A2A is an open protocol that lets agents communicate over HTTP regardless of framework
AgentExecutoris the single class you implement — it bridges A2A tasks to your CrewAI crewrun_in_executoris essential: CrewAI is synchronous, so you must offload it to a thread to keep the async server responsivemanifest.ymlreplaces both the Procfile and manualcfcommands — it's the single source of truth for deployment.cfignoreprevents sensitive files (.env) from being uploaded to CFVCAP_APPLICATIONprovides the public URL at runtime — the Agent Card picks it up automatically, no manualAPP_URLenv var needed
- ✅ Set up your development space
- ✅ Build a basic agent
- ✅ Add custom tools
- ✅ Build a multi-agent system
- ✅ Add the Grounding Service
- ✅ Discover Connected Crimes
- ✅ Solve the crime
- ✅ Deploy your agent to CF with A2A (this exercise)
- 📌 Integrate your agent into SAP Joule
Issue: cf push fails with health check failed
- Solution: Check
cf logs investigator-crew-<YOUR NAME> --recent. Common causes:- Missing
requirements.txtdependency - Import error in
server.pyorinvestigator_crew.py - Service binding not found — verify the service name matches exactly (
generative-ai-hub)
- Missing
Issue: ModuleNotFoundError: No module named 'a2a'
- Solution: Ensure
a2a-sdk[http-server]is inrequirements.txt. The square brackets are important — they install optional HTTP server dependencies.
Issue: /.well-known/agent-card.json returns a wrong URL
- Solution: The app URL is detected automatically from
VCAP_APPLICATIONat runtime. If it's wrong, check that the app was pushed successfully and that CF has assigned a route. Runcf appsto verify the route, then checkcf logs investigator-crew-<YOUR NAME> --recentfor errors.
Issue: App crashes immediately after startup
- Solution: You likely have a missing environment variable. Check
cf logs investigator-crew-<YOUR NAME> --recentforKeyErrororAttributeError. Verify allenv:values inmanifest.ymlare set, especiallyRPT1_DEPLOYMENT_URL.
Issue: .env was accidentally uploaded and credentials are exposed
- Solution: Add
.envto.cfignore, runcf pushto overwrite, then rotate your API credentials immediately in SAP BTP.
Issue: Error: relation between task and context not found when calling the agent
- Solution: The app likely restarted and lost its in-memory task state. Ensure
--workers 1is in yourcommandand checkcf logs investigator-crew-<YOUR NAME> --recentfor unexpected restarts.