Lab 2: Develop a custom agent
In this lab you build your own agent, the Throughput Checker, and run it on your computer. You tell it the downlink throughput of a network, and it tells you if the network meets its throughput target. In Lab 3, it will learn to measure the throughput by itself.
âšī¸ About this example. The agent you build in Labs 2 to 4 is a small example. It shows how to develop an agent, how to integrate it with the platform, by calling the agents already running on MX-PDK and deploying it next to them, and how to extend a blueprint with it. The job it does, comparing a throughput with a target, does not need AI at all: a short script would do it faster, cheaper and more reliably. Agents are worth it when a task needs language: understanding free-form questions, deciding what to look at, and explaining results.
You use two tools from the BubbleRAN Agentic Toolkit (BAT):
- the BAT CLI (
batcommand): it creates the project for you and runs common tasks; - the BAT ADK (
bat-adkPython library): you write the agent's logic with it. It is built on LangGraph.
By the end of this lab you will be able to:
- create a blueprint and an agent with
bat; - configure an agent: its settings, its secrets and its agent card;
- give an LLM a tool, and let it use the tool in a ReAct loop;
- run the agent and test it with
curl.
Before you start, read the prerequisites for Labs 2 to 5. This lab runs on your computer only: you don't need the cluster yet.
Part 1: The ideaâ
"Network o-ran has 42 Mbps of downlink throughput. Does it meet the target?"
â
ââââââââââââââââââââââŧââââââââââââââââââââââ
â judge (the ReAct loop) â
â â
â 1. The LLM reads the question. â
â 2. It calls the tool check_target(42). â
â 3. The tool answers: BELOW TARGET. â
â 4. The LLM writes the final answer. â
ââââââââââââââââââââââŦââââââââââââââââââââââ
â
"o-ran: BELOW TARGET, 42 Mbps measured, target 50 Mbps"
Your agent uses an LLM in a ReAct loop (Reason + Act): the LLM reads the request, decides which tool to call, reads the tool's result, and repeats until it can answer. A tool is a plain Python function that the LLM can call.
Here the work is split in two:
- the LLM handles the language: it finds the throughput in your sentence, even if you write it in your own words;
- the tool
check_targetapplies the rule: it compares the throughput with the target. Rules belong in code, because code always gives the same result.
Part 2: Install the toolsâ
Step 1: Install uvâ
uv installs Python and the Python packages your agent needs. You don't need
to install Python yourself.
curl -LsSf https://astral.sh/uv/install.sh | sh
Close your terminal and open a new one, then check:
uv --version
You should see a version number, for example uv 0.11.17.
Step 2: Install the BAT CLIâ
uv tool install bat-cli==2026.10
bat version
You should see:
bat-cli 2026.10
Part 3: Create the blueprint and the agentâ
A blueprint is a folder that holds one or more agents. All its agents
are packed into one container image, like the Observability blueprint of
Lab 1. You create a blueprint called tutorial, with one agent called
throughput_checker:
bat init blueprint tutorial
cd tutorial
bat add agent throughput_checker --clients Checker
You should see:
Created BAT blueprint in: /home/you/tutorial
Files written: 11
Next: cd into it and run `bat add agent <name>`.
Added agent 'throughput_checker' to blueprint 'tutorial'.
Files written: 9
Run it with: make throughput_checker
- The agent name uses
_, not-, because it is also a Python name. --clients Checkercreates an LLM client calledCheckerClient: the piece of code that talks to the LLM.
The blueprint now looks like this:
tutorial/
âââ .env your secrets, like the API key (never share it)
âââ pyproject.toml the Python packages the blueprint needs
âââ __main__.py starts the agent named on the command line
âââ Makefile shortcuts: run an agent, build the image
âââ Dockerfile how to build the container image (Lab 4)
âââ tutorial.spec how to pack the code into one program (Lab 4)
âââ docker-compose.yaml runs the agents with Docker (not used here)
âââ README.md
âââ throughput_checker/ your agent
âââ app.py starts the agent
âââ config.yaml its settings: port, LLM, agents it calls
âââ agent.json its agent card
âââ src/
âââ graph.py its logic
âââ llm_clients/
âââ checker_client.py its instructions for the LLM
There are also a few hidden files (.gitignore, .dockerignore,
.python-version) and some __init__.py files. You don't need to change
them.
Open pyproject.toml and check the dependencies line. It must be:
pyproject.toml: the dependencies (click to open)
dependencies = [
"bat-adk[openai,telemetry]==2026.10"
]
If it is different, change it to match.
Part 4: Configure the agentâ
This lab uses OpenAI. If you use another LLM, also make the changes listed in Using another LLM.
Step 1: The settings: throughput_checker/config.yamlâ
Replace the content of throughput_checker/config.yaml with:
throughput_checker/config.yaml (click to open)
# This agent's own config. `make throughput_checker` reads this file.
agent_card: throughput_checker/agent.json
endpoint:
url: http://localhost
port: 9900
model:
provider: openai
name: gpt-6-luna
reasoning_effort: low
checkpoints: false
telemetry:
output: []
| Part | Meaning |
|---|---|
agent_card | Where the agent card is. |
endpoint | Where your agent listens: http://localhost, port 9900. |
model | The LLM your agent uses. It is the same LLM as in the fabric of Lab 1. |
checkpoints: false | The agent does not remember previous questions: each question is answered on its own. |
telemetry | Tracing is off on your computer (output: []). In the cluster, the AIFabric turns it on. See Phoenix. |
Step 2: The secrets: .envâ
Open the file .env at the root of the blueprint (in tutorial/, not in
throughput_checker/) and replace its content with:
.env (click to open)
OPENAI_API_KEY=<YOUR_API_KEY>
TARGET_DL_MBPS=50
OPENAI_API_KEY: your OpenAI key. It stays on your computer: it is never put in the container image.TARGET_DL_MBPS: the downlink throughput target, in Mbps.
Step 3: The agent card: throughput_checker/agent.jsonâ
The agent card tells other agents, and the Supervisor, what your agent does.
Replace the content of throughput_checker/agent.json with:
throughput_checker/agent.json (click to open)
{
"name": "Throughput Checker",
"description": "Checks if a network meets its downlink throughput target.\n\nGive it the measured downlink throughput of one or more networks, and it compares each one with the target set by the operator.",
"version": "1.0.0",
"capabilities": {
"streaming": true,
"pushNotifications": false
},
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
"skills": [
{
"id": "throughput-target-check",
"name": "Throughput target check",
"description": "Compare a measured downlink throughput with the target.",
"examples": [
"Network <network-name> has 42 Mbps of downlink throughput. Does it meet the target?",
"Is 120 Mbps of downlink throughput enough for the target?"
],
"tags": ["throughput", "target", "5G"]
}
]
}
description: one short sentence first, then the details. The Supervisor reads the whole card to decide when to call your agent, so be clear.skillsandexamples: what the agent can do, with example questions.<network-name>is a placeholder: MX-UI lets you fill it in.
Part 5: Write the codeâ
Step 1: The tool: tools.pyâ
Create a new file, throughput_checker/src/tools.py, with this content:
throughput_checker/src/tools.py (click to open)
from langchain_core.tools import tool
import os
@tool
def check_target(
measured_mbps: float,
) -> str:
"""Compare a measured downlink throughput, in Mbps, with the target."""
target = float(os.getenv("TARGET_DL_MBPS", "50"))
verdict = "MEETS TARGET" if measured_mbps >= target else "BELOW TARGET"
return f"{verdict}: {measured_mbps} Mbps measured, target {target} Mbps"
@toolturns the function into a tool the LLM can call.- The LLM reads the function's name, its parameters and its docstring
(the text between
""") to know what the tool does and when to call it. Write them clearly. - The target comes from the environment variable
TARGET_DL_MBPS(your.envfile), or is50.
Step 2: The LLM client: checker_client.pyâ
The LLM client holds the instructions your agent gives the LLM. Replace
the content of throughput_checker/src/llm_clients/checker_client.py with:
throughput_checker/src/llm_clients/checker_client.py (click to open)
from bat.chat_model_client import ChatModelClient, ChatModelClientConfig
class CheckerClient(ChatModelClient):
SYSTEM_INSTRUCTIONS = (
"You check whether 5G networks meet their downlink throughput "
"target.\n"
"The text you get gives the measured downlink throughput of one or "
"more networks.\n"
"For each network, call the tool check_target with its measured "
"value, in Mbps.\n"
"Then answer in at most 5 short lines, one per network, with the "
"result of the tool.\n"
"If the text gives no downlink throughput number, do not call the "
"tool: answer UNKNOWN and say why.\n"
"Never compare the numbers yourself, and never invent a number."
)
def __init__(
self,
tools,
):
super().__init__(
system_instructions=self.SYSTEM_INSTRUCTIONS,
chat_model_config=ChatModelClientConfig.load(
client_name="CheckerClient"
),
tools=tools,
)
SYSTEM_INSTRUCTIONStells the LLM what to do, when to call the tool, and how to answer. Clear rules give answers that are short and always in the same shape.ChatModelClientConfig.loadreads themodelpart of yourconfig.yaml, so the client knows which LLM to call.toolsare the tools the LLM may call. You pass them in Step 3.
Step 3: The logic: graph.pyâ
An agent built with the BAT ADK is a graph: a set of steps (nodes)
connected by arrows (edges). Each request goes through the graph from
START to END. Your graph has one node, the ReAct loop:
START âââē judge âââē END
Replace the content of throughput_checker/src/graph.py with:
throughput_checker/src/graph.py (click to open)
from .llm_clients.checker_client import CheckerClient
from .tools import check_target
from bat.agent import AgentGraph, AgentState, AgentTaskResult, AgentTaskStatus
from bat.prebuilt import ReActLoop
from langgraph.graph import END, START
from typing import Optional, Self
from typing_extensions import override
class ThroughputCheckerAgentState(AgentState):
query: str
response: Optional[str] = None
@classmethod
@override
def from_query(
cls,
query: str,
) -> Self:
return cls(query=query)
@override
def to_task_result(
self,
) -> AgentTaskResult:
return AgentTaskResult(
task_status=(
AgentTaskStatus.AGENT_TASK_STATUS_COMPLETED
if self.response
else AgentTaskStatus.AGENT_TASK_STATUS_WORKING
),
content=self.response or "Generating response...",
)
class ThroughputCheckerAgentGraph(AgentGraph):
@override
def setup(
self,
config,
) -> None:
self.checker_client = CheckerClient(
tools=[check_target],
)
self.judge = ReActLoop(
config=config,
StateType=ThroughputCheckerAgentState,
loop_name="judge",
chat_model_client=self.checker_client,
input_key="query",
output_key="response",
)
self.graph_builder.add_node("judge", self.judge.as_runnable())
self.graph_builder.add_edge(START, "judge")
self.graph_builder.add_edge("judge", END)
What the code does:
The state (ThroughputCheckerAgentState) is the memory of one request.
Every node reads it and writes to it.
| Field | Holds |
|---|---|
query | The question the user sent. |
response | The final answer of your agent. |
from_querycreates the state when a question arrives.to_task_resulttells BAT when the answer is ready: as long asresponseis empty, the agent is still working; once it is set, the task is completed andresponseis sent back.
The graph (ThroughputCheckerAgentGraph) is built in setup, once,
when the agent starts:
self.checker_clientis the LLM client of Step 2, with the toolcheck_target.self.judgeis a ReActLoop, a ready-made node of the BAT ADK. It gives the text inqueryto the LLM, runs the tools the LLM asks for, gives their results back to the LLM, and writes the final answer inresponse.add_nodeadds the node, andadd_edgeconnects it:START â judge â END.
Part 6: Run and testâ
Step 1: Start the agentâ
In the tutorial/ folder, run:
make throughput_checker
The first time, uv downloads Python and the packages: it takes a minute or
two. You should see, among other lines:
INFO ... - Agent configuration loaded [checkpoints=False] [#agents=0] [#mcp=0]
INFO ... - Loading AgentCard from 'throughput_checker/agent.json'
INFO ... - ChatModelClient CheckerClient initialized with: model=openai:gpt-6-luna, #tools=1
INFO: Uvicorn running on http://0.0.0.0:9900 (Press CTRL+C to quit)
#tools=1: the LLM client has your tool.Uvicorn running on http://0.0.0.0:9900: your agent is ready on port9900.
Keep this terminal open. To stop the agent, press Ctrl+C.
Step 2: Check that it answersâ
Open another terminal:
curl http://localhost:9900/ping
You should see:
"pong"
Then look at its agent card:
curl http://localhost:9900/.well-known/agent-card.json
You should see your card, starting with
{"name":"Throughput Checker", ....
Step 3: Ask a questionâ
Agents talk with the A2A protocol: a JSON message sent over HTTP. Send
your question with curl:
curl -s -N http://localhost:9900/ \
-H "Content-Type: application/json" \
-H "A2A-Version: 1.0" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "SendStreamingMessage",
"params": {
"message": {
"message_id": "1",
"role": "ROLE_USER",
"parts": [{"text": "Network o-ran has 42 Mbps of downlink throughput. Does it meet the target?"}]
}
}
}' | grep -o '"text": "[^"]*"'
You should see three lines:
"text": "Network o-ran has 42 Mbps of downlink throughput. Does it meet the target?"
"text": "Generating response..."
"text": "o-ran: BELOW TARGET â 42.0 Mbps measured; target is 50.0 Mbps."
- your question;
- a progress message, sent while the agent works;
- the final answer. The LLM's wording can change a little from one run to the next.
Now try these questions in the same command, and see how the LLM decides when to call the tool:
Network a has 30 Mbps and network b has 80 Mbps of downlink throughput. Which ones meet the target?The LLM calls the tool twice, once per network, and answers one line per network.Does network o-ran meet its throughput target?There is no number, so the LLM does not call the tool. It answers UNKNOWN.
Step 4: Change the targetâ
Open .env, set TARGET_DL_MBPS=40, stop the agent with Ctrl+C and start
it again with make throughput_checker. Ask the first question again: the
answer becomes MEETS TARGET. The rule changed, while the LLM and its
instructions stayed the same.
đĄ Optional: watch the ReAct loop. With Phoenix, a trace viewer, you can see each step of your agent. For the first question, the trace shows the loop: an LLM call that asks for
check_target, the tool call, and a second LLM call that writes the answer. To set it up, see Phoenix: an agent on your computer.
If something goes wrongâ
| What you see | What to do |
|---|---|
bat: command not found | Run uv tool update-shell, then close your terminal and open a new one. If it still fails, run uv tool install bat-cli==2026.10 again. |
make throughput_checker stops with Missing credentials ... OPENAI_API_KEY | Your API key is missing. Check the .env file at the root of tutorial/ (Part 4, Step 2). |
make throughput_checker stops with No module named ...tools | The file tools.py is missing or in the wrong folder. It must be in throughput_checker/src/ (Part 5, Step 1). |
address already in use | Another program uses port 9900. Stop it, or change port in config.yaml and use the new port in your curl commands. |
The answer is Agent execution failed (...) | Read the error in the terminal where the agent runs. It is often the API key or the network connection. |
| The answer compares the numbers, but the result looks wrong | Check TARGET_DL_MBPS in .env, and restart the agent after each change. |
| Still stuck? | Contact BubbleRAN customer support through your usual support channel. |
What you learnedâ
bat init blueprintandbat add agentcreate a blueprint and an agent, ready to run.- An agent has settings (
config.yaml), secrets (.env) and an agent card (agent.json). - With the BAT ADK, the logic is a graph of nodes that share a state.
- A tool is a Python function the LLM can call. A ReActLoop lets the LLM decide when to call it. Rules go in tools; language goes to the LLM.
make <agent>runs an agent, andcurltalks to it with the A2A protocol.
In Lab 3, your agent stops asking you for the number: it asks the KPI agent running in your cluster.