NAT Lesson 0 — Getting Started with the NeMo Agent Toolkit
A teaching walkthrough of the smallest complete NVIDIA NeMo Agent Toolkit (NAT) project: one custom tool, one built-in tool, one ReAct agent, running against any OpenAI-compatible LLM endpoint or the hosted NVIDIA API. This is the first of three lessons; it teaches the primitives every later NAT project builds on.
Companion repo: github.com/unixtool1192/nat-getting-started (private — ask Tony for access). The lesson is also self-contained: every file it describes is shown in full, so you can recreate the project by following along without the repo.
Related notes:
- LLM Tool Calling
- Building-an-AI-Agent-from-Scratch
- Model-Context-Protocol-MCP
- uv-and-Python-Workspaces.
1. What NAT is, in one idea
Most agent frameworks make you write the agent in code. NAT inverts this: agents, tools, and models are components you declare in YAML, and the toolkit assembles and runs them. The Python you write is only the tool implementations — everything else (which agent loop, which LLM, which tools it may call) is configuration.
The systems analogy: NAT is to agents what systemd units are to daemons. You don’t write the supervision loop; you write the service (your tool) and a declarative unit file (the YAML), and the runtime wires it together. Swap the LLM backend and nothing recompiles — you edited config.
Why that matters commercially: the same workflow file runs against a laptop Ollama model, a hosted NVIDIA API model, or a self-hosted NIM container in a customer’s datacenter, by changing one YAML block or one environment variable.
2. The five primitives
A minimal NAT project is ~50 lines of Python plus one YAML file. Five mechanisms make it work; every NAT repo you will ever read is these five, scaled up.
2.1 The config class — a typed struct for your tool
class GettingStartedFunctionConfig(FunctionBaseConfig, name="getting_started"):
prefix: str = Field(default="Echo:", description="Prefix to add before the echoed text.")
This is a Pydantic model — think a C struct with validation compiled in. Every field has a type, a default, and a description; if the YAML supplies prefix: 42 where a string is expected, the load fails at parse time, not at 2 a.m. in a tool call. The name="getting_started" is the key detail: it registers the string that YAML will use as _type to refer to this component. One class ↔ one _type.
2.2 @register_function — a function pointer registered at load time
@register_function(config_type=GettingStartedFunctionConfig,
framework_wrappers=[LLMFrameworkEnum.LANGCHAIN])
async def getting_started_function(config, builder):
async def _echo(text: str) -> str:
"""Takes a text input and echoes back with a pre-defined prefix."""
return f"{config.prefix} {text}"
yield FunctionInfo.from_fn(_echo, description=_echo.__doc__)
A Python decorator runs when the module is imported, not when the function is called. @register_function is therefore the equivalent of a constructor-attribute function in C (__attribute__((constructor))) or a kernel module’s module_init(): at load time it inserts an entry into NAT’s global dispatch table mapping GettingStartedFunctionConfig → this builder function. Nothing calls getting_started_function by name, ever — the toolkit finds it through the table.
Three details worth teaching explicitly:
- The inner function is the tool.
_echois what the agent actually invokes. Its type hints become the tool’s parameter schema and its docstring becomes the description the LLM reads when deciding whether to call it. Bad docstring → the model can’t tell what the tool is for → it never calls it. The docstring is a prompt. yield, notreturn. The outer function is a generator used as a context manager. Everything before theyieldis setup, everything after (if any) is teardown, and the state stays alive for the workflow’s whole lifetime — the same shape as opening a file descriptor or socket in a daemon’s init and closing it on SIGTERM. A tool that owns a database connection opens it before theyieldand closes it after.configis closed over._echoreadsconfig.prefixfrom the enclosing scope — the validated YAML values are baked into the tool at build time.
2.3 Entry points — how NAT finds your code
[project.entry-points.'nat.components']
getting_started = "getting_started.register"
Registration only happens if the module gets imported — so who imports it? Python packaging has a plugin-discovery mechanism called entry points: a package declares, in its own pyproject.toml, “I provide a component for the nat.components group, load me via this module.” At startup NAT scans that group across every installed package — the same pattern as a daemon scanning /etc/cron.d/ or the dynamic linker walking ld.so.conf.d/: drop-in discovery, no central registry file to edit. register.py itself is one line: import the module(s) whose decorators need to fire.
2.4 The YAML workflow — declarative assembly
functions:
current_datetime:
_type: current_datetime # built into NAT
getting_started:
_type: getting_started # yours, found via the entry point
prefix: "Hello:"
llms:
openai_llm:
_type: openai
model_name: ${NAT_L0_LLM_MODEL:-gemma4:31b}
base_url: ${NAT_L0_LLM_BASE_URL:-http://localhost:11434/v1}
api_key: ${NAT_L0_LLM_API_KEY:-not-needed}
workflow:
_type: react_agent
llm_name: openai_llm
tool_names: [current_datetime, getting_started]
parse_agent_response_max_retries: 3
Read it bottom-up: the workflow is a ReAct agent using the LLM named openai_llm and allowed to call two tools. Each _type selects a config class registered under that name, and the block is validated against that class — so the whole file is a typed schema, not stringly-typed key-value soup.
The ${VAR:-default} syntax is exactly bash default expansion, evaluated by NAT at load time. This is what makes the file portable: the defaults suit a laptop; environment variables retarget it at a lab server or a customer endpoint without touching the file.
2.5 The ReAct loop — what the agent actually does
ReAct (“Reason + Act”) is a text protocol between the framework and the LLM. Each turn, the model must emit:
Thought: I need the current time to answer this.
Action: current_datetime
Action Input: {}
The framework parses that, runs the tool, appends the result as Observation: ..., and loops until the model emits Final Answer: .... It is a while loop around string parsing — nothing more mystical than a protocol state machine, and like any text protocol it fails when the peer sends garbage. Small local models occasionally emit an empty or malformed turn; that is what parse_agent_response_max_retries: 3 is for (retry the turn instead of aborting the query). This distinction — tool calling as text protocol (ReAct) vs. native structured tool-calling APIs — is covered in LLM Tool Calling.
3. One workflow, three run modes
The same YAML runs three ways — the same relationship as one C codebase built as a command-line binary, a daemon, and a library:
| Mode | Command | Analogy |
|---|---|---|
| CLI one-shot | nat run --config_file <yml> --input "..." | ./prog args |
| REST server | nat serve --config_file <yml> → OpenAI-compatible API on :8000 | daemon behind a socket |
| Embedded | load_config() + run_workflow() in your own Python | linking libprog.so |
nat serve is the commercially interesting one: any client that speaks the OpenAI chat-completions API (curl, an existing app, another agent) can now call your workflow — the agent becomes infrastructure.
4. Swapping the backend
The lesson’s punchline: the workflow never changes; only the llms: block does.
| Backend | Provider _type | What changes |
|---|---|---|
| Local Ollama | openai | nothing — defaults |
| Remote OpenAI-compatible endpoint | openai | base_url env var |
| Hosted NVIDIA API (build.nvidia.com) | nim | second YAML, NVIDIA_API_KEY |
| Self-hosted NIM container | nim | add base_url to the same block |
The nim provider defaults to NVIDIA’s hosted endpoint when base_url is omitted — so the identical config file demos on the free hosted tier and deploys on-prem against a NIM container. One caveat worth setting as expectation: the provider’s max_tokens default (300) can truncate ReAct reasoning traces; set ~1024 for agents.
Configuration hygiene: .env and precedence
Two conventions from larger software shops, both worth adopting:
.env.examplecommitted,.envgitignored. The repo documents the shape of its configuration with placeholder values; real values (keys!) live only in the untracked.env. Load withuv run --env-file .env ...or plain bash:set -a; source .env; set +a.- Precedence: the shell wins.
--env-fileonly fills in variables not already exported — the same mental model as Makefile variables vs.make VAR=x. Practical consequence: a stale value in.envsilently beats the config file’s default, because the config default only applies when the variable is unset. When a run behaves as if your config edit didn’t happen, check the.envfirst.
5. Field notes: three real failures and their diagnosis
All three occurred while building this lesson; all three are the kind of thing a student will hit in the first hour.
403 Forbiddenfrom the NVIDIA API — but/v1/modelsreturns 200. The key authenticates but has no inference entitlement. Both NGC registry keys (fordocker pull nvcr.io/...) and build.nvidia.com API keys start withnvapi-, and they are not interchangeable. Diagnosis rule: list-works-but-invoke-403 means wrong key scope (or exhausted credits), not a typo. Generate the key from the build.nvidia.com API-keys page specifically.- Socket timeout on a hosted model. A specific hosted model hung for minutes while others answered in one second. Hosted catalog models vary in load; the fix is
NAT_L0_NIM_MODEL=<other-model>— which is exactly why the model name should be an env var, not hardcoded. ReActAgentParsingFailedErrormid-conversation. The local model returned an empty completion once, and the ReAct parser aborted the query. Transient model flakiness, not a code bug — the retry setting in §2.5 absorbs it.
The meta-lesson: an agent stack has three independent failure domains — your code, the framework, and the model/endpoint — and the first debugging step is always attributing the failure to the right one (curl the endpoint directly to take the framework out of the loop).
6. Exercises
- Add a tool. Write
reverse_text(returns its input reversed): config class →@register_function→ import inregister.py→ add tofunctions:andtool_names. Ask the agent to reverse something and watch it choose the right tool. Then delete the docstring and watch it stop choosing it. - Trace the loop. Run with
verbose: trueon the workflow and read the raw Thought/Action/Observation turns. - Break the protocol. Point the config at a small non-instruct model and observe ReAct parsing failures firsthand — then reason about why
tool_calling_agent(native structured calls) is more robust when the backing model supports it. - Serve it.
nat serve, then call it with curl from another machine.
7. Where this goes next
- Lesson 1 — a real tool hub: wrapping standalone tools from separate repos as NAT functions, serving them over Model-Context-Protocol-MCP, and adding Phoenix tracing.
- Lesson 2 — the payoff demo: the same task implemented in four agent frameworks (LangChain, CrewAI, LlamaIndex, Semantic Kernel), wrapped in NAT and compared through one observability pane.