Skip to navigation

NOOA Adapter

Build agents using NVIDIA NOOA with the Band SDK

This tutorial shows you how to create an agent using the NooaAdapter. The adapter runs a NOOA (NVIDIA-labs Object Oriented Agents) agent class in Band chat rooms.

A NOOA agent is a Python class: its docstring is the prompt, its fields are state, its plain methods are tools, and an async def whose body is ... is completed by a model that writes and runs Python against self. The adapter adds the Band platform tools to that class as async methods on self.room.

The model’s generated Python runs inside your agent process. NOOA’s code checks are not a security boundary: generated code can read anything the process can, including its environment variables and credentials. Run NOOA agents in a container, a VM, or NVIDIA OpenShell, never on a machine whose files or credentials matter. See Isolation.

Prerequisites

Before starting, make sure you’ve completed the Setup tutorial:

  • SDK installed with NOOA support (Python 3.12 or 3.13, on Linux or macOS)
  • Agent created on the platform
  • .env and agent_config.yaml configured
  • Verified your setup works

Install the NOOA extra:

uv add "band-sdk[nooa]"

The NOOA adapter requires band-sdk 5.1.0 or later. NOOA supports Python 3.12 and 3.13 on Linux and macOS only. On other Python versions or on Windows, the nooa extra installs nothing, and importing NooaAdapter raises ModuleNotFoundError.

NOOA routes model calls through LiteLLM, so you also need an API key for your model provider. The examples on this page use Anthropic and read ANTHROPIC_API_KEY.


Create Your Agent

A NOOA agent needs two files. NOOA tells the model that the globals of the agent class’s module are in scope for the code it writes, so keep the agent class in a module of its own and put your startup code, credentials and adapter setup in another (see Keep the Agent Module Lean).

Create assistant.py with the agent class:

assistant.py
from nooa.unifiedllm.registry import get_llm_client
from band.adapters import NooaRoomAgent
class Assistant(NooaRoomAgent, llm=get_llm_client("anthropic/claude-sonnet-5-5")):
"""You are a helpful assistant in a Band chat room."""

Create agent.py to run it:

agent.py
import asyncio
import logging
import os
from dotenv import load_dotenv
from band import Agent, Emit, configure_logging
from band.adapters import NooaAdapter
from band.config import load_agent_config
from assistant import Assistant
logger = logging.getLogger(__name__)
async def main():
load_dotenv()
configure_logging(root_level="INFO")
# Load agent credentials
agent_id, api_key = load_agent_config("my_agent")
# Pass the agent class, not an instance.
# Post tool calls only; see Execution Reporting before enabling thoughts.
adapter = NooaAdapter(agent=Assistant, emit={Emit.TOOL_CALLS})
# Create and run the agent
agent = Agent.create(
adapter=adapter,
agent_id=agent_id,
api_key=api_key,
ws_url=os.getenv("BAND_WS_URL", "wss://app.band.ai/api/v1/socket/websocket"),
rest_url=os.getenv("BAND_REST_URL", "https://app.band.ai"),
)
logger.info("Agent is running! Press Ctrl+C to stop.")
await agent.run()
if __name__ == "__main__":
asyncio.run(main())

Your agent class must subclass NooaRoomAgent and be constructible without arguments. Pass the class itself to NooaAdapter(agent=...), not an instance: the adapter creates one instance per room.

emit={Emit.TOOL_CALLS} is deliberate. When you omit emit, the adapter also posts the model’s raw reasoning to the room. See Execution Reporting.


Run the Agent

Start your agent:

uv run python agent.py

You should see lines like:

2026-10-14 10:20:04 [INFO] __main__: Agent is running! Press Ctrl+C to stop.
2026-10-14 10:20:05 [INFO] band.agent: Agent started: My Agent (band-sdk 5.1.0)

The band.agent line is the one that confirms a working connection: it is logged only after the SDK has authenticated and connected. See Confirming a successful connection.


Test Your Agent

1

Add Agent to a Chat Room

Go to Band and either create a new room or open an existing one. Open the room’s Participants panel, select Add participants, and choose your agent under Agents.

2

Send a Message

In the chat room, mention your agent:

@MyAgent Hello! Can you help me?
3

See the Response

Your agent’s code calls self.room.band_send_message(...) and its reply appears in the room.


How It Works

When your agent runs:

  1. Connection - The SDK connects to Band via WebSocket
  2. Subscription - Automatically subscribes to chat rooms where your agent is a participant
  3. Message filtering - Only processes messages that mention your agent
  4. Processing - Calls the room’s agent instance’s respond(message) method. Its body is ..., so NOOA has the model write and run Python against self to handle the message
  5. Response - The model’s code replies with await self.room.band_send_message(...), or declines with await self.room.band_no_reply(...)

Band tools reach the model as async methods on self.room, not as JSON tool schemas. Each method’s signature and docstring come from the same centralized tool definitions every Band adapter uses, and the adapter adds a full reference of the self.room methods to the model’s context so it sees every argument description. A failed tool call raises an error inside the model’s code that states the reason, so the model can read it and retry.

One instance per room: Each room gets its own agent instance. A NOOA agent keeps its event log for its lifetime, so the instance is the room’s conversation memory, and fields you set on self never leak between rooms. When a new session starts in a room, the adapter replaces the instance and seeds it with the room’s platform transcript.

Replies only through tools: The return value of respond is never posted to the room. A message reaches the room only through band_send_message. A turn that neither replies nor declines is reported as failed.


Choosing a Model

Set the model with the llm class keyword. get_llm_client takes a LiteLLM model string in provider/model-name format, or the key of a model configured in NOOA’s own model registry:

from nooa.unifiedllm.registry import get_llm_client
class Assistant(NooaRoomAgent, llm=get_llm_client("anthropic/claude-sonnet-5-5")):
"""You are a helpful assistant in a Band chat room."""

Each provider requires its own API key environment variable, for example ANTHROPIC_API_KEY for anthropic/... models or OPENAI_API_KEY for openai/... models. See the LiteLLM provider docs for the variable each provider reads.


Agent State and Methods

Because a NOOA agent is a Python class, you give it state with fields and tools with plain methods. The model calls your methods from the Python it writes, next to the Band tools on self.room:

class NoteTaker(NooaRoomAgent, llm=get_llm_client("anthropic/claude-sonnet-5-5")):
"""You are NoteTaker, a concise assistant that keeps notes for this room.
When someone asks you to remember something, add it with `self.remember`.
When asked what you know, answer from `self.notes`.
"""
notes: list[str]
def __init__(self) -> None:
super().__init__()
self.notes = []
def remember(self, note: str) -> int:
"""Store a note for this room and return how many notes there are."""
self.notes.append(note)
return len(self.notes)

Since each room has its own instance, notes holds only that room’s notes. Like Assistant above, this class goes in its own module.


Configuration Options

The NooaAdapter supports these configuration options:

from band import Emit
from band.adapters import NooaAdapter, NooaAdapterConfig
adapter = NooaAdapter(
# Prompt settings (optional)
NooaAdapterConfig(
# Extra instructions appended to the Band platform instructions
custom_section="Always answer in one short paragraph.",
),
# Required: a NooaRoomAgent subclass (the class, not an instance)
agent=Assistant,
# Optional: (InputModel, handler) custom tools, exposed on self.room
additional_tools=[],
# Optional: which execution events to post to the room
emit={Emit.TOOL_CALLS},
)
ParameterTypeRequiredDescription
configNooaAdapterConfig | NoneNoPrompt settings. custom_section is appended to the Band platform instructions
agenttype[NooaRoomAgent]YesYour agent class. Raises TypeError if it is not a NooaRoomAgent subclass
additional_toolslist[tuple[type[BaseModel], Callable]] | NoneNoCustom tools added to self.room
history_converterNooaHistoryConverter | NoneNoCustom converter for the room transcript

The adapter also accepts the feature options emit, capabilities, include_tools, exclude_tools, and include_categories as keyword arguments. See the NooaAdapter reference.

There is no system_prompt override. Your agent class’s docstring is its system prompt, and the Band platform instructions are added to the model’s context alongside it.


Add Custom Instructions

Customize your agent’s behavior with custom_section:

adapter = NooaAdapter(
NooaAdapterConfig(
custom_section="""
You are a helpful assistant that specializes in answering
questions about Python programming. Be concise and include
code examples when helpful.
""",
),
agent=Assistant,
)

Custom Tools

Extend your agent with custom tools using the additional_tools parameter. Each tool is a tuple of a Pydantic model (the input schema) and a handler that receives the validated model. The adapter adds each one as an async method on self.room, next to the Band tools:

Keep the tool’s input model and handler with your startup code, and only the agent class in its own module. order_desk.py:

order_desk.py
from nooa.unifiedllm.registry import get_llm_client
from band.adapters import NooaRoomAgent
class OrderDesk(NooaRoomAgent, llm=get_llm_client("anthropic/claude-sonnet-5-5")):
"""You are OrderDesk, a concise order-support assistant.
Look orders up with `self.room.orderstatus(...)` before answering. If a
call fails, read the error, fix the arguments and try again. Reply in the
room with what you found.
"""

In agent.py:

agent.py (excerpt)
from pydantic import BaseModel, Field
from band import Emit
from band.adapters import NooaAdapter
from order_desk import OrderDesk
ORDERS = {"ORD-1234": "shipped", "ORD-5678": "processing"}
class OrderStatusInput(BaseModel):
"""Look up the shipping status of an order."""
order_id: str = Field(
pattern=r"^ORD-\d{4}$",
description="The order id, always 'ORD-' followed by four digits",
)
def order_status(args: OrderStatusInput) -> dict[str, str]:
"""Return the order's status, or 'unknown' for an order that does not exist."""
return {"order_id": args.order_id, "status": ORDERS.get(args.order_id, "unknown")}
adapter = NooaAdapter(
agent=OrderDesk,
additional_tools=[(OrderStatusInput, order_status)],
emit={Emit.TOOL_CALLS},
)

The method name is derived from the Pydantic model class name: the Input suffix is removed and the rest is lowercased, so OrderStatusInput becomes self.room.orderstatus(...). Arguments are validated against the model. A validation error is raised into the model’s code naming the broken rule, so the model can read it and retry.


Execution Reporting

The NOOA adapter supports three execution events:

EmitWhat it posts
Emit.TOOL_CALLStool_call and tool_result events for each self.room call
Emit.THOUGHTSEach model call’s raw reasoning_content, as a thought event
Emit.USAGEToken usage for the turn, summed across every model call, including calls made by child agents the turn delegates to

All three are on by default when you omit emit. Emit.THOUGHTS posts the model’s raw reasoning to the room, which can expose chain-of-thought. Pass an explicit emit without it to keep reasoning out of the room.

from band import Emit
# Post tool calls only: no reasoning, no usage
adapter = NooaAdapter(agent=Assistant, emit={Emit.TOOL_CALLS})

An image read with band_read_room_file reaches the model as NOOA Image objects it can show(); the tool_result event carries a placeholder instead of the image bytes. Room file tools need capabilities={Capability.FILES} and a deployment with room file transfer enabled; see Capability.FILES.


Complete Example

Here’s a full example with agent state, a custom instruction, and execution reporting limited to tool calls.

note_taker.py:

note_taker.py
from nooa.unifiedllm.registry import get_llm_client
from band.adapters import NooaRoomAgent
class NoteTaker(NooaRoomAgent, llm=get_llm_client("anthropic/claude-sonnet-5-5")):
"""You are NoteTaker, a concise assistant that keeps notes for this room.
When someone asks you to remember something, add it with `self.remember`.
When asked what you know, answer from `self.notes`.
"""
notes: list[str]
def __init__(self) -> None:
super().__init__()
self.notes = []
def remember(self, note: str) -> int:
"""Store a note for this room and return how many notes there are."""
self.notes.append(note)
return len(self.notes)

agent.py:

agent.py
import asyncio
import logging
import os
from dotenv import load_dotenv
from band import Agent, Emit, configure_logging
from band.adapters import NooaAdapter, NooaAdapterConfig
from band.config import load_agent_config
from note_taker import NoteTaker
logger = logging.getLogger(__name__)
async def main():
load_dotenv()
configure_logging(root_level="INFO")
agent_id, api_key = load_agent_config("my_agent")
adapter = NooaAdapter(
NooaAdapterConfig(custom_section="Keep every reply under three sentences."),
agent=NoteTaker,
emit={Emit.TOOL_CALLS},
)
agent = Agent.create(
adapter=adapter,
agent_id=agent_id,
api_key=api_key,
ws_url=os.getenv("BAND_WS_URL", "wss://app.band.ai/api/v1/socket/websocket"),
rest_url=os.getenv("BAND_REST_URL", "https://app.band.ai"),
)
logger.info("NoteTaker is running! Press Ctrl+C to stop.")
await agent.run()
if __name__ == "__main__":
asyncio.run(main())

Isolation

The model writes Python, and that Python runs inside your agent process. NOOA’s AST checks are not a security boundary: generated code can reach anything the process can, including environment variables, files, network, and the adapter’s own objects.

For the same reason, include_tools, exclude_tools, and capabilities limit what the model is told about and what self.room offers, not what its code can call.

  • Run NOOA agents in a container, a VM, or NVIDIA OpenShell
  • Give the process only the credentials it needs: its Band agent key and its model provider key
  • NOOA’s optional Linux sandbox backend (CodeActConfig(execution_backend="sandbox")) adds kernel guardrails, but is not a replacement for running in an isolated environment

The SDK repository includes a Dockerfile for the NOOA examples that runs the model’s code as an unprivileged user that cannot write the app directory.

Importing nooa installs a SIGUSR2 debug handler, and NOOA enables OpenTelemetry tracing when its trace viewer (nooa start-dev) is reachable.

Keep the Agent Module Lean

NOOA lists the globals of every module in an agent class’s MRO to the model as already in scope. If your agent class shares a module with main(), load_agent_config, or your adapter setup, the model is told it can call them, and load_agent_config returns your Band API key. Keep each agent class in a module that holds only the class and the imports it needs, as every example on this page does. NooaRoomAgent itself lives in a module that holds nothing else for the same reason.


Debug Mode

If your agent isn’t responding as expected, enable debug logging in agent.py:

agent.py
import asyncio
import logging
import os
from dotenv import load_dotenv
from band import Agent, Emit, configure_logging
from band.adapters import NooaAdapter
from band.config import load_agent_config
from assistant import Assistant
logger = logging.getLogger(__name__)
async def main():
load_dotenv()
# Enable debug logging for the SDK
configure_logging(level="DEBUG", root_level="INFO")
agent_id, api_key = load_agent_config("my_agent")
adapter = NooaAdapter(agent=Assistant, emit={Emit.TOOL_CALLS})
agent = Agent.create(
adapter=adapter,
agent_id=agent_id,
api_key=api_key,
ws_url=os.getenv("BAND_WS_URL", "wss://app.band.ai/api/v1/socket/websocket"),
rest_url=os.getenv("BAND_REST_URL", "https://app.band.ai"),
)
logger.info("Agent running with DEBUG logging. Press Ctrl+C to stop.")
await agent.run()
if __name__ == "__main__":
asyncio.run(main())

With debug logging enabled, you’ll see detailed output including:

  • WebSocket connection events
  • Room subscriptions
  • Message processing lifecycle
  • How many transcript lines seeded each new room instance
  • Errors and exceptions

If the agent runs but never replies, check that your class docstring doesn’t tell the model to answer through the return value. Only self.room.band_send_message(...) posts to the room.


Architecture Notes

The NOOA adapter differs from other adapters in a few key ways:

Tools as Methods, Not Schemas:

  • Band tools are async methods on self.room, called from the model’s generated Python
  • NOOA documents self to the model from class annotations, so when the agent starts the adapter subclasses your class with room annotated as a type that has exactly the tools your features allow
  • The contacts hub room gets its own tool surface that always includes the contact tools

Turn-Scoped Tool Calls:

  • Each turn binds its tools to that turn. A tool call from a task the model’s code left running after respond returned raises BandToolError, and a call still in flight is cancelled, so no effect commits after the turn is judged
  • If NOOA raises after the model already replied or declined, the adapter logs the error and keeps the turn rather than posting an error after the reply

Minimal Model Scope:

  • NOOA puts the globals of every module in an agent class’s MRO into the model’s execution context. NooaRoomAgent lives in a module that holds nothing else, so adapter internals are never advertised to the model. Keep your own agent module lean for the same reason; see Keep the Agent Module Lean

Examples

The SDK repository has runnable NOOA examples:

FileShows
01_basic_agent.pyA NooaRoomAgent subclass with per-room state and a plain-Python method
02_tom_agent.pyTom the cat; run with 03_jerry_agent.py
03_jerry_agent.pyJerry the mouse; the two NOOA agents talk in one room
04_custom_tools.pyA typed custom tool on self.room, with validation errors the model recovers from

Next Steps