Object Oriented AI Agents with NVIDIA’s OO Agents Framework
Most agent frameworks in circulation today share the same basic shape. You write a system prompt as a string, you register a list of tools as JSON schemas, and you hand both to a runtime that decides when to call which tool. Everything about the agent is external metadata: the prompt is a text blob, the tools are dictionaries, the state lives in whatever data structures you happen to pass around, and the interface between the model and your Python code is a serialization boundary that you write by hand.
NVIDIA’s Object Oriented Agents framework, packaged as nooa, takes a different approach. An agent is a Python class. Its system prompt is the class docstring. Each of its capabilities is a method. Some of those methods are ordinary deterministic Python and behave as tools the language model may call. Other methods have ... as their body, and the framework fills in the implementation at runtime by prompting the language model with the method’s signature, docstring, and return type. State is just class fields with type annotations. There is no separate tool schema, no manual JSON glue, and no drift between what the prompt promises and what the code enforces.
This chapter builds a complete example on top of that idea: a travel planner agent that recommends a destination, prices a trip against a budget, drafts a day by day itinerary, offers packing advice, and produces a structured trip plan. It runs on NVIDIA’s free NIM inference endpoint using the same NVIDIA_client.py helper introduced in the previous chapter.
Why represent an agent as a class
Consider the operations a travel planner needs to perform. Some are unambiguous and cheap:
- Look up the flight cost to a city.
- Multiply the hotel nightly rate by the number of nights.
- Filter destinations to those under a budget.
Others require judgment, taste, or synthesis:
- Pick the single best city for a traveler whose interests are “hiking, ancient history, and lively food markets”.
- Draft a plausible seven day itinerary in Cusco.
- Write a memorable one sentence packing tip.
The first group belongs in Python. Nothing is gained, and much is lost, by asking a language model to multiply 180 by 6. The second group belongs to the language model, because the space of plausible answers is huge and no closed form procedure captures what “best” means.
In a traditional agent framework you would express this split by writing Python functions for the deterministic operations, wrapping them in JSON tool schemas, and writing prompts for the generative operations as separate strings. In nooa the split shows up directly in the class body. Deterministic operations are regular methods. Generative operations are async methods with ... for a body. Both live on self, so the language model can call the deterministic tools while producing an answer for the generative ones. The type annotations on each method double as the interface contract the framework enforces on the model’s output.
What we will build
The finished program is a single file, travel_planner_agent.py, driven by a Makefile, with a small README.md for reference. The plan_trip method is the top level orchestrator. It calls one language model method to pick a destination, a second to draft an itinerary, a third for a packing tip, and finally one direct call to the low level NVIDIA_client.complete helper for a local greeting phrase. Along the way it uses the deterministic methods for cost math.
The eventual return value is a Pydantic TravelPlan with a numeric total, a boolean flag for whether the plan fits the budget, and a list of DayPlan records for each day of the trip.
The mock destination database
The example ships with a hand written dictionary of five destinations. In a real application this would be a call to a booking API, but keeping it inline makes the example easy to run and easy to hack. Here is a representative entry so you can see the shape before you read the code that consumes it:
1 "Cusco": {
2 "country": "Peru",
3 "flight": 900,
4 "hotel_night": 90,
5 "vibe": "Inca ruins, Andean markets, coca-tea culture",
6 }
Each destination has a country, a round trip flight cost in USD, a nightly hotel rate in USD, and a short “vibe” string that captures the city’s character in a form the language model can reason over. Five entries is deliberately small; the goal is that a reader can add a sixth destination in ten seconds and immediately see the agent consider it.
Structured output: the Pydantic models
Before the agent class itself, the file defines three Pydantic models. These are the return types the language model must produce. The framework parses model output against these classes, so any field that is missing, mistyped, or out of range triggers an automatic retry or a raised exception rather than silently corrupt data flowing forward.
1 class DayPlan(BaseModel):
2 day: int = Field(ge=1)
3 theme: str
4 morning: str
5 afternoon: str
6 evening: str
7
8
9 class Itinerary(BaseModel):
10 days: list[DayPlan]
11
12
13 class TravelPlan(BaseModel):
14 destination: str
15 country: str
16 total_cost_usd: float = Field(ge=0)
17 duration_days: int = Field(ge=1)
18 within_budget: bool
19 days: list[DayPlan]
20 packing_tip: str
21 local_phrase: str
Notice Itinerary wraps a list[DayPlan]. This is a small defensive choice. Structured output from language models works most reliably when the top level type is a single object, so draft_itinerary returns Itinerary and the orchestrator unwraps .days before packing them into the final TravelPlan. The Field(ge=1) and Field(ge=0) constraints let Pydantic reject nonsensical outputs like a day zero or a negative cost.
Configuring the language model
nooa uses the litellm library under the hood, so it can point at any OpenAI compatible endpoint by combining the openai/<model> prefix with an api_base and api_key. Reusing the constants from the previous chapter’s NVIDIA_client.py file, the model client is:
1 from NVIDIA_client import DEFAULT_MODEL, _BASE_URL, complete
2
3 from nooa import Agent
4 from nooa.unifiedllm.registry import get_llm_client
5
6 llm = get_llm_client(
7 f"openai/{DEFAULT_MODEL}",
8 api_base=_BASE_URL,
9 api_key=os.getenv("NVIDIA_API_KEY"),
10 )
This llm object is then passed to the class definition itself with class TravelPlannerAgent(Agent, llm=llm):. Every generative method on the class will use this client. Because we are importing _BASE_URL and DEFAULT_MODEL from the earlier chapter’s file, swapping models is a one line change in one place.
The agent class, method by method
The class is short enough that we can walk through it in three passes: the docstring, the deterministic tools, and the generative methods.
The docstring as a system prompt
1 class TravelPlannerAgent(Agent, llm=llm):
2 """You are a seasoned travel planner. You blend hard cost math from the
3 deterministic helper methods with creative, culturally-aware itinerary
4 design. Never invent prices - always call the helper methods for numeric
5 facts. Prefer destinations whose vibe genuinely matches the traveller's
6 stated interests."""
Two things worth noticing. First, the docstring is the system prompt. There is no separate system_prompt= argument. Second, the docstring names the helper methods by name and instructs the model to prefer them for numeric facts. This is how you steer the model toward using your deterministic tools rather than guessing.
Deterministic tools
Five ordinary methods act as tools the language model can call from any generative step:
1 def list_destinations(self) -> list[str]:
2 """Every city you can plan a trip to."""
3 return list(DESTINATIONS.keys())
4
5 def get_vibe(self, city: str) -> str:
6 """One-line character description of the city."""
7 return str(DESTINATIONS[city]["vibe"])
8
9 def get_country(self, city: str) -> str:
10 """Country the city sits in."""
11 return str(DESTINATIONS[city]["country"])
12
13 def estimate_cost(self, city: str, nights: int) -> float:
14 """Round-trip flight + nights * hotel, in USD."""
15 d = DESTINATIONS[city]
16 return float(d["flight"]) + float(d["hotel_night"]) * nights
17
18 def cheapest_within(self, budget: float, nights: int) -> list[str]:
19 """Cities whose total (flight + nights * hotel) is <= budget."""
20 return [c for c in DESTINATIONS if self.estimate_cost(c, nights) <= budget]
These are plain Python. There is no @tool decorator, no schema declaration, no registration step. nooa’s runtime gives the language model a REPL-like environment with self bound, and the model calls these methods by writing Python such as self.cheapest_within(2500, 6). Because the signatures and docstrings are already in the class, the model has everything it needs to pick the right call without additional glue.
Generative methods
The three generative methods are declared the same way as normal Python methods, but their bodies are just ...:
1 async def recommend_destination(
2 self, interests: str, budget: float, nights: int
3 ) -> str:
4 """Pick the SINGLE best city for `interests` within `budget` for
5 `nights` nights. First call `cheapest_within` to filter, then
6 compare `get_vibe` for each candidate. Return only the city name -
7 no punctuation, no explanation."""
8 ...
9
10 async def draft_itinerary(self, city: str, nights: int) -> Itinerary:
11 """Produce a `nights + 1` day itinerary for `city`. Use `get_vibe`
12 for local flavor. Number days starting at 1. Each day should have
13 a theme plus morning/afternoon/evening activities."""
14 ...
15
16 async def packing_tip(self, city: str) -> str:
17 """One vivid sentence of packing advice tailored to `city`."""
18 ...
At runtime, nooa intercepts every call to one of these methods, packages the class docstring, the method docstring, the method signature, and the return type into a prompt, sends that prompt to the configured llm, and validates the reply against the annotated return type. If the return type is a Pydantic model the reply is parsed into an instance. If validation fails the framework can retry.
The docstrings do more than describe intent. In recommend_destination the docstring names the two deterministic helper methods the model should call, so the model plans, filters, and compares before answering. In draft_itinerary the docstring establishes the shape of a day and the numbering convention.
The orchestrator
The last method is regular async Python. It calls the generative methods, calls the deterministic methods, and calls the sibling NVIDIA_client.complete helper directly for a bonus local greeting phrase:
1 async def plan_trip(
2 self, interests: str, budget: float, nights: int
3 ) -> TravelPlan:
4 """End-to-end: choose city -> cost it -> itinerary -> packing tip
5 -> a local greeting phrase (via a direct NVIDIA_client call)."""
6 raw = (await self.recommend_destination(interests, budget, nights)).strip()
7 city = raw.strip('"').splitlines()[0].strip()
8 if city not in DESTINATIONS:
9 affordable = self.cheapest_within(budget, nights)
10 city = affordable[0] if affordable else next(iter(DESTINATIONS))
11
12 cost = self.estimate_cost(city, nights)
13 itinerary = await self.draft_itinerary(city, nights)
14 tip = await self.packing_tip(city)
15
16 phrase = complete(
17 f"Give ONE short local greeting phrase a traveller could use in "
18 f"{city}, {self.get_country(city)}, with a phonetic pronunciation "
19 f"in parentheses. Reply with the phrase only - no preamble."
20 ).strip()
21
22 return TravelPlan(
23 destination=city,
24 country=self.get_country(city),
25 total_cost_usd=cost,
26 duration_days=nights + 1,
27 within_budget=cost <= budget,
28 days=itinerary.days,
29 packing_tip=tip,
30 local_phrase=phrase,
31 )
Two design choices in this method deserve attention. First, even though recommend_destination is instructed to return only a city name, the orchestrator defensively strips quotes, takes the first line, and falls back to the cheapest affordable option if the reply is not a known city. Real language model output is noisy, and a wrapper of a few lines is far cheaper than a corrupted downstream stage. Second, the complete call at the end is deliberately synchronous. It shows that the object oriented framework composes cleanly with plain HTTP style calls to the same endpoint; you do not have to route everything through the agent to benefit from it.
Complete file listing
The full travel_planner_agent.py:
1 # travel_planner_agent.py - Trip Planner built with NVIDIA OO Agents (nooa)
2
3 # /// script
4 # requires-python = ">=3.10"
5 # dependencies = [
6 # "nooa",
7 # "openai>=1.0",
8 # "pydantic>=2",
9 # ]
10 # ///
11
12 import asyncio
13 import os
14 import sys
15 from pathlib import Path
16
17 from pydantic import BaseModel, Field
18
19 _SIBLING = Path(__file__).resolve().parent.parent / "llm_public_apis"
20 sys.path.insert(0, str(_SIBLING))
21 from NVIDIA_client import DEFAULT_MODEL, _BASE_URL, complete # noqa: E402
22
23 from nooa import Agent # noqa: E402
24 from nooa.unifiedllm.registry import get_llm_client # noqa: E402
25
26
27 if not os.getenv("NVIDIA_API_KEY"):
28 raise SystemExit("Set NVIDIA_API_KEY first (free key at https://build.nvidia.com)")
29
30 llm = get_llm_client(
31 f"openai/{DEFAULT_MODEL}",
32 api_base=_BASE_URL,
33 api_key=os.getenv("NVIDIA_API_KEY"),
34 )
35
36
37 DESTINATIONS: dict[str, dict[str, object]] = {
38 "Kyoto": {"country": "Japan", "flight": 1400, "hotel_night": 180, "vibe": "zen temples, bamboo forests, matcha rituals"},
39 "Reykjavik": {"country": "Iceland", "flight": 650, "hotel_night": 220, "vibe": "geothermal lagoons, aurora borealis, glacier hikes"},
40 "Cusco": {"country": "Peru", "flight": 900, "hotel_night": 90, "vibe": "Inca ruins, Andean markets, coca-tea culture"},
41 "Lisbon": {"country": "Portugal", "flight": 550, "hotel_night": 140, "vibe": "tile-clad hillsides, fado music, pastel de nata"},
42 "Marrakech": {"country": "Morocco", "flight": 700, "hotel_night": 110, "vibe": "labyrinth souks, Sahara excursions, mint-tea evenings"},
43 }
44
45
46 class DayPlan(BaseModel):
47 day: int = Field(ge=1)
48 theme: str
49 morning: str
50 afternoon: str
51 evening: str
52
53
54 class Itinerary(BaseModel):
55 days: list[DayPlan]
56
57
58 class TravelPlan(BaseModel):
59 destination: str
60 country: str
61 total_cost_usd: float = Field(ge=0)
62 duration_days: int = Field(ge=1)
63 within_budget: bool
64 days: list[DayPlan]
65 packing_tip: str
66 local_phrase: str
67
68
69 class TravelPlannerAgent(Agent, llm=llm):
70 """You are a seasoned travel planner. You blend hard cost math from the
71 deterministic helper methods with creative, culturally-aware itinerary
72 design. Never invent prices - always call the helper methods for numeric
73 facts. Prefer destinations whose vibe genuinely matches the traveller's
74 stated interests."""
75
76 def list_destinations(self) -> list[str]:
77 """Every city you can plan a trip to."""
78 return list(DESTINATIONS.keys())
79
80 def get_vibe(self, city: str) -> str:
81 """One-line character description of the city."""
82 return str(DESTINATIONS[city]["vibe"])
83
84 def get_country(self, city: str) -> str:
85 """Country the city sits in."""
86 return str(DESTINATIONS[city]["country"])
87
88 def estimate_cost(self, city: str, nights: int) -> float:
89 """Round-trip flight + nights * hotel, in USD."""
90 d = DESTINATIONS[city]
91 return float(d["flight"]) + float(d["hotel_night"]) * nights
92
93 def cheapest_within(self, budget: float, nights: int) -> list[str]:
94 """Cities whose total (flight + nights * hotel) is <= budget."""
95 return [c for c in DESTINATIONS if self.estimate_cost(c, nights) <= budget]
96
97 async def recommend_destination(
98 self, interests: str, budget: float, nights: int
99 ) -> str:
100 """Pick the SINGLE best city for `interests` within `budget` for
101 `nights` nights. First call `cheapest_within` to filter, then
102 compare `get_vibe` for each candidate. Return only the city name."""
103 ...
104
105 async def draft_itinerary(self, city: str, nights: int) -> Itinerary:
106 """Produce a `nights + 1` day itinerary for `city`. Use `get_vibe`
107 for local flavor. Number days starting at 1."""
108 ...
109
110 async def packing_tip(self, city: str) -> str:
111 """One vivid sentence of packing advice tailored to `city`."""
112 ...
113
114 async def plan_trip(
115 self, interests: str, budget: float, nights: int
116 ) -> TravelPlan:
117 """End-to-end: choose city, cost it, itinerary, packing tip, phrase."""
118 raw = (await self.recommend_destination(interests, budget, nights)).strip()
119 city = raw.strip('"').splitlines()[0].strip()
120 if city not in DESTINATIONS:
121 affordable = self.cheapest_within(budget, nights)
122 city = affordable[0] if affordable else next(iter(DESTINATIONS))
123
124 cost = self.estimate_cost(city, nights)
125 itinerary = await self.draft_itinerary(city, nights)
126 tip = await self.packing_tip(city)
127
128 phrase = complete(
129 f"Give ONE short local greeting phrase a traveller could use in "
130 f"{city}, {self.get_country(city)}, with a phonetic pronunciation "
131 f"in parentheses. Reply with the phrase only - no preamble."
132 ).strip()
133
134 return TravelPlan(
135 destination=city,
136 country=self.get_country(city),
137 total_cost_usd=cost,
138 duration_days=nights + 1,
139 within_budget=cost <= budget,
140 days=itinerary.days,
141 packing_tip=tip,
142 local_phrase=phrase,
143 )
144
145
146 async def main() -> None:
147 agent = TravelPlannerAgent()
148 plan = await agent.plan_trip(
149 interests="hiking, ancient history, and lively food markets",
150 budget=2500.0,
151 nights=6,
152 )
153 print(plan.model_dump_json(indent=2))
154
155
156 if __name__ == "__main__":
157 asyncio.run(main())
The Makefile and PEP 723 script metadata
The block near the top of the file with # /// script is a PEP 723 header. It tells uv which Python version and which packages the script needs. Because of this header, uv run travel_planner_agent.py will create an ephemeral virtual environment on first run, install nooa, openai, and pydantic into it, and cache the environment for subsequent runs. No pyproject.toml is needed and no uv sync step is required.
The Makefile uses this feature directly:
1 .PHONY: run clean
2
3 run:
4 uv run travel_planner_agent.py
5
6 clean:
7 find . -type d -name __pycache__ -not -path './.venv/*' -exec rm -rf {} +
8 find . -type d -name .ruff_cache -exec rm -rf {} +
Running the example
After exporting your NVIDIA API key and running make run, the output is a single JSON document. Every language model call is nondeterministic, so your exact wording will differ, but the structure and the numeric fields are stable. Here is a representative run:
1 {
2 "destination": "Cusco",
3 "country": "Peru",
4 "total_cost_usd": 1440.0,
5 "duration_days": 7,
6 "within_budget": true,
7 "days": [
8 {
9 "day": 1,
10 "theme": "Arrival and gentle acclimatization",
11 "morning": "Land in Cusco and check in slowly; sip coca tea to settle the altitude.",
12 "afternoon": "Amble through San Blas, ducking into artisan studios and Inca stonework alleys.",
13 "evening": "Early quinoa-and-trout dinner near the Plaza de Armas."
14 },
15 {
16 "day": 2,
17 "theme": "Sacred Valley markets",
18 "morning": "Ride to Pisac; wander the Sunday market stacked with alpaca weavings and roasted corn.",
19 "afternoon": "Climb the Pisac ruins for a first taste of Inca terracing.",
20 "evening": "Return to Cusco for pisco sours and lomo saltado."
21 },
22 {
23 "day": 3,
24 "theme": "Andean day hike",
25 "morning": "Drive to the Rainbow Mountain trailhead and start the climb at first light.",
26 "afternoon": "Descend slowly; picnic near a glacial stream.",
27 "evening": "Recover with a hearty aji de gallina back in Cusco."
28 },
29 {
30 "day": 4,
31 "theme": "Ollantaytambo and the train",
32 "morning": "Explore Ollantaytambo's fortress and its living Inca street grid.",
33 "afternoon": "Board the afternoon train to Aguas Calientes at the foot of Machu Picchu.",
34 "evening": "Riverside dinner and an early night before sunrise."
35 },
36 {
37 "day": 5,
38 "theme": "Machu Picchu",
39 "morning": "First bus up the switchbacks; walk the classic circuit as mist lifts off the ruins.",
40 "afternoon": "Hike to the Sun Gate for a wider view.",
41 "evening": "Return train to Ollantaytambo, taxi back to Cusco."
42 },
43 {
44 "day": 6,
45 "theme": "Food markets and museums",
46 "morning": "Graze the San Pedro market; try chicha morada and salteñas.",
47 "afternoon": "Visit the Inka Museum for context on the ruins you have walked.",
48 "evening": "Farewell dinner at a Novoandino tasting menu."
49 },
50 {
51 "day": 7,
52 "theme": "Departure",
53 "morning": "Slow breakfast, last coca tea, final walk around the Plaza de Armas.",
54 "afternoon": "Airport transfer.",
55 "evening": "Fly home."
56 }
57 ],
58 "packing_tip": "Layer merino for icy dawn ascents that surrender to strong Andean sun by noon.",
59 "local_phrase": "Allillanchu (ah-lee-YAHN-choo)"
60 }
Interpreting the output
Several things in this output are worth pausing on.
The total_cost_usd is 1440.0, which is exactly 900 + 90 * 6, matching the Cusco entry in DESTINATIONS. The number was computed by estimate_cost, not invented by the language model. This is the payoff of writing deterministic tool methods: numeric fields are always right, because they never leave Python.
The within_budget flag is true, because 1440.0 <= 2500.0. Again, this is a plain Python comparison in the orchestrator, not a judgment call by the model. If the recommended city had come back over budget, this flag would be false and the caller could take corrective action.
The destination is Cusco. The traveler’s stated interests were “hiking, ancient history, and lively food markets”. Compare against the vibe strings for each destination: Kyoto is temples and matcha, Reykjavik is glaciers and lagoons, Lisbon is tiles and pastries, Marrakech is souks and Sahara excursions. Cusco is the only destination whose vibe hits all three of hiking, ancient history, and food markets at once. The model made a defensible pick using only the short vibe descriptions in the database, filtered against the budget by way of cheapest_within.
The itinerary is seven DayPlan objects, matching nights + 1 = 7. Every day has theme, morning, afternoon, and evening populated as strings. This is the Pydantic contract at work. If the model had returned a day with a missing field or an integer where a string was expected, nooa would have raised or retried, and the caller would never see a half formed record.
The local_phrase came from the direct complete call rather than from the agent. This is important because it shows that you can freely mix framework calls and low level calls. Both hit the same NVIDIA NIM endpoint using the same NVIDIA_API_KEY.
Wrap Up
The design principle behind nooa is that the boundary between “code the developer wrote” and “code the model wrote” should be a method boundary, not a serialization boundary. Once you accept that framing, most of the machinery of traditional agent frameworks becomes unnecessary. There is no tool registry because methods are already registered by being on the class. There is no prompt file because docstrings are prompts. There is no output parser because return types are already annotated and Pydantic already knows how to validate against them.
The travel planner in this chapter has fewer than two hundred lines of code and demonstrates six framework capabilities: class based agent definition, docstring driven prompting, deterministic tool methods, language model generation methods, structured Pydantic output, and clean composition with plain HTTP calls to the same endpoint. Every one of those capabilities is expressed as ordinary Python. That is the point.
The example is deliberately small enough to hack. Add a destination and the recommender considers it on the next run. Add a helper method such as weather_score(city, month) -> float and any generative method that mentions it in its docstring can call it. Add a whole new generative method with a ... body and a signature, and it works from the first call. The framework fades into the background and leaves you writing Python.
Optional Practice Problems
The following exercises range from small edits to more ambitious extensions. All of them build on the code in this chapter.
Add a destination. Append a new entry to
DESTINATIONS, for example Reykjavik replaced with Queenstown or Ushuaia, and change the traveler’s interests to something that clearly favors your new city. Confirm from the JSON output that the model picks it.Add a deterministic tool method. Write a
season_score(self, city: str, month: str) -> floatmethod that returns a number between zero and one based on any rules you like (for example, prefer Kyoto in April, Cusco in June, Marrakech in October). Update the docstring onrecommend_destinationto mention the new tool and rerun. Inspect the output to confirm the picks shift with the month.Add a new generation method. Declare
async def budget_swap_suggestions(self, city: str, target_savings: float) -> list[str]:with a...body and a docstring instructing the model to propose two or three concrete substitutions (for example, “swap the Andean train for a shared van, save around one hundred fifty USD”). Wire it intoplan_tripand add the returned list toTravelPlan.Persist the plan to disk. Extend the orchestrator to write the returned
TravelPlanas JSON to a file whose name includes the destination and the current date. Confirm the file round trips throughTravelPlan.model_validate_json.Swap the model. Change the
DEFAULT_MODELconstant in../llm_public_apis/NVIDIA_client.pyto a different model available on NVIDIA NIM. Rerun and compare the tone and structure of the itineraries.Multi city trip. Extend the agent with a
plan_multi_city_trip(interests, budget, nights, cities: int)generative method that returns a list ofTravelPlanobjects, one per leg. You will need to think carefully about how to split the budget between legs; a deterministic helper method that allocates the budget proportionally to leg length is a good starting point.Better recovery from a bad recommendation. The orchestrator’s current fallback picks the first affordable city if the model returns something unrecognized. Improve this by writing a second attempt: prompt the model with the specific list of affordable cities and ask it to pick from that list. Only fall back to the arbitrary first pick if that second attempt also fails.
Add a small evaluation harness. Write a script that calls
plan_tripwith three different(interests, budget, nights)inputs, checks that each returned plan is within budget and has the expected number of days, and prints a pass/fail summary. This is a good foundation for regression testing your agent as you extend it.