NOOA: Agents as Python Classes — Creating Agents from Scratch

NVIDIA's NOOA framework treats an AI agent as a plain Python object: fields are state, methods are capabilities, docstrings are prompts, and an ellipsis body means 'the LLM implements this.' Here's how it works and how to build your first agent.

Most agent frameworks make you learn their abstractions: prompt templates, tool schemas, callback hooks, workflow graphs, memory stores. NVIDIA’s NOOA (Object-Oriented Agents) asks a different question: what if an agent were just a Python class? Fields are state, methods are capabilities, docstrings are prompts, type annotations are contracts. If you know Python, you already know 90% of the framework.

Anatomy of a NOOA agent class: docstring as prompt, typed fields as state, real method bodies as deterministic code, ellipsis bodies as LLM-driven loops
Every part of the class already means something — NOOA takes it literally.

The idea, concretely

from nooa import Agent

class SupportAgent(Agent, llm=llm):
    """You are a support agent."""

    order_db: OrderDB

    def refund_ok(self, order: Order) -> bool:
        return order.delivered and order.days <= 30

    async def triage(self, msg: str) -> Ticket:
        """Create a typed support ticket."""
        ...

There are exactly two kinds of methods here, and you can tell them apart at a glance:

  • Real body (refund_ok) — plain deterministic Python. It never touches the LLM. Unit-test it, debug it, refactor it with your IDE.
  • ... body (triage) — a generation method. The signature is the contract (str in, Ticket out), the docstring is the task prompt, and the runtime implements the body with an LLM-driven agentic loop.

And the trick that replaces tool schemas entirely: code as action. The model doesn’t call tools through JSON schemas — it writes Python in a Jupyter-style REPL with access to self. Your methods and their type annotations are the callable interface. No separate tool definitions to keep in sync.

How this differs from traditional frameworks

Traditional frameworks split prompts, tools, callbacks, workflows and memory into separate abstractions; NOOA unifies them into one Python class
Five abstractions to glue together — or one object to refactor.
Traditional frameworksNOOA
Agent definitionPrompt string + tool schemas + callbacks + workflow graphOne Python class
ToolsHand-written JSON schemas, kept in sync with codeMethods + type annotations; model writes Python against self
StateExternal store / context object you wire upTyped fields; live objects passed by reference
Deterministic logicNodes in the workflow graphMethods with real bodies — plain Python
TestingFramework-specific harnessespytest, debuggers, type checkers — the usual Python toolchain
Model choicePer-framework integrationsModel-agnostic via LiteLLM (Anthropic, OpenAI, Ollama, vLLM…)

The deepest difference is philosophical: traditional frameworks treat the agent as a configuration to be assembled; NOOA treats it as software to be written. That means the skills you already have — testing, tracing, refactoring, version control — keep working.

When NOOA fits

  • You live in Python. If your team thinks in classes and type annotations, NOOA has near-zero conceptual overhead.
  • You need deterministic + agentic mixed. The real-body/...-body split makes it natural to keep business rules in code and delegate judgment calls to the model — in the same class.
  • You want typed contracts. Methods declare -> Ticket and the runtime validates the model’s output against it, with auto-retry on mismatch. Structured output isn’t a separate feature; it’s the default.
  • You run models locally. The LiteLLM registry includes Ollama and vLLM targets, so the same agent class runs against a local Qwen as against a frontier API.
  • You care about observability. Every LLM call, code execution, and method invocation is traced by default, with a trace viewer (nooa start-dev) that shows parent-child spans.

It’s research software (NVIDIA says so upfront), so expect rough edges — but the design is deliberately boring in the right places.

Architecture: what a ... call actually does

NOOA generation method lifecycle: build prompt from docstring and signature, LLM writes Python, execute in REPL with access to self, validate typed output, retry or return
The lifecycle of a generation method — prompt, code, execute, validate.

Three mechanics worth knowing:

  1. Prompt assembly. The runtime composes the docstring, the signature (types included), and the live self — the model sees your actual object, not a serialized copy.
  2. CodeAct execution. Generated Python runs in a REPL with self, imports, and helpers in scope. Generated code passes AST checks and module deny-lists first — but NVIDIA is explicit that these are guardrails, not a boundary. Run code-executing agents in a container or VM, not on your laptop.
  3. Typed I/O with auto-retry. The output must validate against the return annotation. If the model returns something that isn’t a Ticket, it gets the type error back as feedback and tries again.

The framework ships as a core package plus optional sub-packages: nooa-cli (the nooa command, trace viewer, eval runner), nooa-acp (coding agent for Agent Client Protocol hosts), nooa-memory (long-term memory), and nooa-bench (benchmarks).

Try the anatomy yourself

Step through the class below — each part lights up with what the runtime does with it. The last step simulates a full run of the agentic loop:

1class SupportAgent(Agent, llm=llm):
2 """You are a support agent."""
3
4 order_db: OrderDB
5
6 def refund_ok(self, order: Order) -> bool:
7 return order.delivered and order.days <= 30
8
9 async def triage(self, msg: str) -> Ticket:
10 """Create a typed support ticket."""
11 ...
Step 1 of 5

The agent is a class

You subclass Agent and pass a model. The class docstring becomes the system prompt — no separate prompt template file, no prompt registry. Rename the class or rewrite the docstring and the agent’s persona changes.

From scratch: your first agent in five minutes

uv init my-agent-project && cd my-agent-project
uv add nooa

Pick a model — anything LiteLLM supports:

from nooa.unifiedllm.registry import get_llm_client

llm = get_llm_client("ollama_chat/qwen3:1.7b",
                     api_base="http://localhost:11434")  # local, no key

Write the agent. Note how little of this is framework API:

import asyncio
from nooa import Agent

class FeedbackAgent(Agent, llm=llm):
    """You are an agent specializing in analyzing customer feedback."""

    async def analyze_feedback(self, text: str) -> str:
        """Analyze customer feedback for sentiment and key topics in one sentence."""
        ...

async def main():
    agent = FeedbackAgent()
    result = await agent.analyze_feedback("Great product, but shipping was slow")
    print(result)

asyncio.run(main())

Rename analyze_feedback to analyze_feedback_briefly and the output changes — the method name, parameters, and docstring are the prompt. That’s the whole mental model.

To see what your agent is doing, start the trace viewer (needs the CLI extras):

uv add "nooa[cli]"
uv run nooa start-dev   # trace viewer on http://localhost:5001

Every LLM call, code execution, and method invocation shows up as a span. If the viewer isn’t running, tracing silently disables itself — no config either way.

The takeaway

NOOA’s bet is that agents don’t need a new programming paradigm — they need Python taken seriously. A class gives you state (fields), capabilities (methods), prompts (docstrings), and contracts (types) with zero new concepts, and the ... convention cleanly separates what the model does from what your code does. If you’ve ever fought a framework’s tool-schema DSL or debugged a prompt buried three config layers deep, the appeal is immediate: it’s just an object, all the way down.


Sources: NOOA on GitHub (Apache 2.0); the NOOA paper on design principles and SWE-bench/Terminal-Bench results; NVIDIA’s developer blog post on agent harness capabilities. Code examples adapted from the repo’s quickstart.

Keep reading