Your First Object-Oriented Agent
This tutorial walks you through the core ideas behind NOOA (Native Object-Oriented Agents). We’ll build a
BaristaAgentthat recommends drinks to sleepy customers. Along the way we’ll see that a NOOA agent is just a Python object — you add tools by adding methods, spin up new agents by instantiating the class, and strongly type its outputs like any regular Python function.
We’re not going to sell you on a whole new paradigm — you won’t walk away having learned some exotic new way to build software. What you will walk away with is plain Python, plus a small sprinkle of magic.
Prerequisites
Install NOOA from GitHub with uv:
uv add "nooa @ git+https://github.com/NVIDIA-NeMo/labs-OO-Agents.git@main"
NOOA is compatible with any LiteLLM-supported model — hosted or local. For hosted providers, you’ll need an API key. Local providers (Ollama, vLLM, any OpenAI-compatible endpoint) need no API key, just an api_base.
Setup
Pick a provider below. Replace "your-api-key" with a real key for hosted providers; local providers don’t need one.
from nooa.unifiedllm.registry import get_llm_client
# model = get_llm_client("claude-haiku-4-5", api_key="your-api-key") # Anthropic
# model = get_llm_client("ollama_chat/qwen3:1.7b", api_base="http://localhost:11434") # Ollama (local, no key)
# model = get_llm_client("hosted_vllm/Qwen/Qwen3-1.7B", api_base="http://localhost:8000/v1") # vLLM (local, no key)
model = get_llm_client("gpt-5-mini", api_key="your-api-key") # OpenAI
Your First Agent
Let’s define our first agent. In NOOA, you build an agent by subclassing Agent — the only required argument is the LLM that will power it. Here is a complete, working agent. It’s small enough to read every line.
from nooa import Agent
class BaristaAgent(Agent, llm=model):
"""You are a friendly barista at a small neighborhood cafe.""" # this becomes the agent's system prompt
async def recommend_drink(self, customer_request: str) -> str:
"""Recommend a single drink to the customer based on what they just told you.
Be warm and concise — one string sentence is plenty."""
...
Instantiate and call it. Notice that we await the method — generation methods are async.
barista = BaristaAgent()
result = await barista.recommend_drink("Hi, I feel so sleepy, I need caffeine ☕️.")
print(result)
You sound like you need a classic double espresso—bright, bold, and ready to wake you right up.
That’s it — a friendly recommendation, ready to serve. So what just happened?
Behind the scenes, NOOA does the work that keeps the interface this Pythonic. A few things worth noticing:
- the class docstring became the agent’s system prompt
- the method docstring became the task description
- the ellipsis (
...) is how you tell NOOA “this method is agentic — hand it off to the LLM instead of running it as regular Python”
Concretely, NOOA quietly assembles a prompt that looks roughly like:
<system prompt>
You are a friendly barista at a small neighborhood cafe.
<available methods>
recommend_drink(customer_request: str) -> str
Recommend a single drink to the customer based on what they just told you. Be warm and concise — one sentence is plenty.
</available methods>
Plus a few other pieces we’ll unpack later. Let’s call the agent one more time, just for fun:
result = await barista.recommend_drink("I would love something that works with a cornetto, but as you know, no capuccinos after 11am.")
print(result)
A creamy cappuccino would be lovely with a cornetto—soft foam, gentle espresso, and just the right breakfast-café feel.
📝 Takeaway: the agent is an object.
Adding Tools (That Is, Adding Class Methods)
The barista just offered a strong caffeine kick — but it’s 9pm and a double espresso is definitely not the move. How do we teach the agent to respect a “no caffeine after 4pm” policy?
In most agent frameworks, you’d register a tool, describe it in a JSON schema, and wire it into the runtime. In NOOA, you just add a method to the class. Anything the agent can see on itself, it can call as a tool.
Let’s add our first tool to BaristaAgent.
from datetime import datetime
from nooa import Agent
class BaristaAgent(Agent, llm=model):
"""You are a friendly barista at a small neighborhood cafe."""
def is_only_decaf_hour(self) -> bool:
"""Return True if we should only be serving decaf right now. After 2pm we go decaf-only so our customers can still sleep tonight."""
return datetime.now().hour >= 14
async def recommend_drink(self, customer_request: str) -> str:
"""Recommend a single drink to the customer based on what they just told you.
Be warm and concise — one string sentence is plenty."""
...
barista = BaristaAgent()
await barista.recommend_drink("Hi, I feel so sleepy.")
'Since it's decaf-only right now, I'd make you a cozy decaf latte to perk up your mood without keeping you up later.'
Two things to sit with:
- You didn’t register
is_only_decaf_houranywhere. No@tool, no JSON schema, notools=[...]list. The framework rendereddoc(self)into the system prompt (that<self>block you saw earlier), and the LLM discovered the method the same way you’d discover it while reading someone else’s code. - The generation method used the helper without being told to. Nothing in
recommend_drink’s docstring mentionsis_only_decaf_hour. The LLM spotted a method that looked relevant, called it, and folded the result into its recommendation. That’s the whole “tools are just methods” idea in a single page.
📝 Takeaway: in NOOA, ordinary Python methods and agentic methods live side by side on the same class. The agent freely calls the deterministic ones as tools — no registration, no schema, no glue code.
Strong Typing
What if our cafe only serves a fixed menu, and we want the agent to only recommend drinks we actually offer?
Most agent frameworks deal in a single data type: text. Text gets passed to tools, text gets exchanged between agents, text comes back as output — and then you painstakingly parse it into JSON, hoping the model got the shape right (which, even with the best models, it doesn’t always). NOOA takes a different approach: because the agent lives inside a Python program, we can strongly type everything.
Let’s put a real type on the return value of recommend_drink:
from enum import Enum
from nooa import Agent
# Define a type for the return value
class Drink(Enum):
ESPRESSO = "espresso"
CAPPUCCINO = "cappuccino"
FLAT_WHITE = "flat white"
class BaristaAgent(Agent, llm=model):
"""You are a friendly barista at a small neighborhood cafe."""
def is_only_decaf_hour(self) -> bool:
"""Return True if we should only be serving decaf right now. After 4pm we go decaf-only so our customers can still sleep tonight."""
return datetime.now().hour >= 16
async def recommend_drink(self, customer_request: str) -> tuple[str, Drink]:
"""Pick the single best drink for the customer from the menu, based on what they told you."""
...
barista = BaristaAgent()
reason, drink = await barista.recommend_drink("Hi, I feel so sleepy.")
print("Barista: ", reason)
print("Recommended drink: ", drink)
print(type(drink))
Barista: It's decaf-only right now, but I'd make you a cozy decaf cappuccino.
Recommended drink: Drink.CAPPUCCINO
<enum 'Drink'>
This isn’t just prompt engineering — NOOA enforces the return type at runtime. If the LLM returns something that isn’t a valid Drink, the framework retries until it produces one. You get real Python objects back, not strings you have to reparse.
# To see what "validation" actually means, try constructing a Drink from
# something that isn't on the menu. Python's Enum machinery rejects it:
try:
Drink("matcha")
except ValueError as e:
print(f"ValueError: {e}")
# When the LLM returns a value that doesn't match the declared type, NOOA
# catches this same error, feeds it back into the next turn as a hint, and
# asks the model to try again. You never see the invalid value in your code.
ValueError: 'matcha' is not a valid Drink
📝 Takeaway: strong typing all the way through.
Agent State
Our cafe has a finite stash of coffee beans, and every drink burns through a few. Since our agent is just a Python object, giving it state is as easy as adding a field in __init__:
from nooa import Agent
from typing import Union
class Drink(Enum):
ESPRESSO = "espresso"
CAPPUCCINO = "cappuccino"
FLAT_WHITE = "flat white"
TEA = "tea"
class BaristaAgent(Agent, llm=model):
"""You are a friendly barista at a small neighborhood cafe."""
def __init__(self, coffee_beans: int) -> None:
super().__init__()
self.coffee_beans = coffee_beans
def is_only_decaf_hour(self) -> bool:
"""Return True if we should only be serving decaf right now. After 6pm we go decaf-only so our customers can still sleep tonight."""
return datetime.now().hour >= 18
def serve(self, drink: Drink) -> Drink:
"""Serve a drink. Deducts 100 beans unless it's tea."""
if drink != Drink.TEA:
self.coffee_beans -= 100
return drink
async def recommend_drink(self, customer_request: str) -> tuple[str, Union[Drink, None]]:
"""Recommend a single drink to the customer based on what they just told you.
Be warm and concise — one sentence is plenty.
We currently have {self.coffee_beans} beans left. If we ran out, recommend a tea.
Call self.serve(drink) with your chosen drink before returning."""
...
barista = BaristaAgent(coffee_beans=200)
for _ in range(3):
barista_answer, drink = await barista.recommend_drink("Something to keep me going, please.")
print(f"Answer: {barista_answer}\nServed: {drink}\nBeans left: {barista.coffee_beans}")
Answer: Absolutely — an espresso should give you a nice little boost.
Served: Drink.ESPRESSO
Beans left: 100
Answer: Absolutely — an espresso should give you a nice little boost.
Served: Drink.ESPRESSO
Beans left: 0
Answer: I'm out of coffee beans just now, but a bright cup of tea will still keep you nicely refreshed.
Served: Drink.TEA
Beans left: 0
📝 Takeaway: the agent “sees” everything on itself — including itself.
What Is Happening Under the Hood?
NOOA doesn’t hide the prompt from you. print_prompt renders exactly what would be sent to the LLM for a given method call — the system prompt, the agent introspection block, and the task. Take a look:
import nooa
await nooa.print_prompt(barista.recommend_drink, customer_request="stressed and running late")
Click to expand the full prompt output
````` === SYSTEM PROMPT [BaristaAgent] ===Look through the output. There’s no hidden state — this is exactly what the LLM sees. A few blocks worth naming:
<system_prompt>— opens with your class docstring. This is the persona the model wears for every method on the class.<strategy_prompt>— a compact rulebook for how to act each turn: what tools exist (execute_python,return_result), when to use which, and how to finish a run. This is what teaches the LLM to “inhabit” the framework, and it’s the same for every agent.<execution_context>— the imports, types, and helpers that will be in scope when the LLM writes code. Anything you import at module level shows up here.<self>— auto-generated documentation of the agent’s public methods and fields, rendered fromdoc(type(self)). This is how the LLM discovers what the agent can do — no separate tool registry needed.- Task prompt — your method docstring plus the rendered argument values for this call. Rendered live at call time.
That’s the whole prompt. No hidden system messages, no template files, no per-tool JSON schemas glued on the side. The reason the “rulebook” stays short is that the framework is plain Python, and the model already knows Python.
Coming later:
print_promptonly shows the outgoing prompt. A later notebook introduces the live trace viewer for watching an entire run unfold — the LLM response, generated code, helper calls, retries, validation, and final return value.
Recap
Five things the barista taught us:
- Ellipsis
...marks a generation method. No decorator, no separate registry. If the body is..., the LLM implements it. - The class docstring is the system prompt; the method docstring is the task. Rewriting a prompt means editing a docstring.
{self.attr}in docstrings is live. Change the attribute, and the next call sees the new value.- Every non-hidden method on
selfis a tool. No@tooldecorator, no JSON schema, no registration step. - The return type annotation is the output contract. Pydantic models are validated and retried automatically.