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 — plus a one-line swap to the native tool-calling 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 and demonstrated as a runnable one-line config swap in §5.
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. Swapping the agent: react_agent vs tool_calling_agent
§4 swapped the backend by editing the llms: block. The repo’s “Lesson 0.5” (config-tool-calling.yml) performs the other instructive swap — same tools, same LLM, but the workflow: block declares a different agent:
workflow:
_type: tool_calling_agent # was: react_agent
llm_name: openai_llm
tool_names: [current_datetime, getting_started]
max_iterations: 8 # note: no parse_agent_response_max_retries
What changed is how a tool request travels from the model back to the framework, and the systems anchor is scraping a CLI’s stdout versus asking it for --json:
react_agentis screen-scraping stdout. The Thought/Action/Action Input protocol of §2.5 is prompted free-form text that NAT parses back out of the completion. Because it’s only a prompt, it runs on any model that can follow instructions — and it fails exactly the way scraping fails: the model emits a slightly-off (or empty) turn and the parser chokes. That is theReActAgentParsingFailedErrorin §6, and the reason the config carries aparse_agent_response_max_retries: 3knob at all.tool_calling_agentis parsing--jsonoutput. NAT hands the tool schemas to the model’s native tool-calling API (LLM Tool Calling), and the request comes back as a structuredtool_callsobject inside the chat-completions response — machine-readable by construction. There is nothing to parse, so there is no retry knob and no mis-parse failure mode. The cost: it only works on models trained for native tool calling. A model without it responds to the schemas with prose describing the calls it would like to make,tool_callscomes back empty, and the loop treats that as the final answer and exits silently — no error, just an agent narrating intentions instead of acting. Building-an-AI-Agent-from-Scratch §4 shows this exact failure with Mistral 7B and gives the debug rule: an agent that returns instantly without calling any tools is a model problem, not a loop problem.
The trade in one line: ReAct fails loudly on any model, native tool calling can’t mis-parse but fails silently on the wrong model. Both of this lesson’s backends — gemma4:31b on Ollama and the hosted NVIDIA nemotron-super model — support native tool calling, so either agent runs on either backend here; react_agent is the portable fallback for models that can’t, and tool_calling_agent is the more robust choice whenever the model qualifies. Lesson 2’s demo repo uses tool_calling_agent for its A/B comparison for exactly this reason.
6. 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).
7. 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
config.ymlat a small non-instruct model and observe ReAct parsing failures firsthand. Then runconfig-tool-calling.yml(§5) on the same model: if the model lacks native tool calling you’ll see the other failure — an instant, silent, tool-free answer. Two protocols, two distinctive failure signatures. - Serve it.
nat serve, then call it with curl from another machine.
8. 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.