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:


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. _echo is 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, not return. The outer function is a generator used as a context manager. Everything before the yield is 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 the yield and closes it after.
  • config is closed over. _echo reads config.prefix from 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:

ModeCommandAnalogy
CLI one-shotnat run --config_file <yml> --input "..."./prog args
REST servernat serve --config_file <yml> → OpenAI-compatible API on :8000daemon behind a socket
Embeddedload_config() + run_workflow() in your own Pythonlinking 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.

BackendProvider _typeWhat changes
Local Ollamaopenainothing — defaults
Remote OpenAI-compatible endpointopenaibase_url env var
Hosted NVIDIA API (build.nvidia.com)nimsecond YAML, NVIDIA_API_KEY
Self-hosted NIM containernimadd 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.example committed, .env gitignored. The repo documents the shape of its configuration with placeholder values; real values (keys!) live only in the untracked .env. Load with uv run --env-file .env ... or plain bash: set -a; source .env; set +a.
  • Precedence: the shell wins. --env-file only fills in variables not already exported — the same mental model as Makefile variables vs. make VAR=x. Practical consequence: a stale value in .env silently 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 .env first.

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_agent is 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 the ReActAgentParsingFailedError in §6, and the reason the config carries a parse_agent_response_max_retries: 3 knob at all.
  • tool_calling_agent is parsing --json output. NAT hands the tool schemas to the model’s native tool-calling API (LLM Tool Calling), and the request comes back as a structured tool_calls object 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_calls comes 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.

  1. 403 Forbidden from the NVIDIA API — but /v1/models returns 200. The key authenticates but has no inference entitlement. Both NGC registry keys (for docker pull nvcr.io/...) and build.nvidia.com API keys start with nvapi-, 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.
  2. 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.
  3. ReActAgentParsingFailedError mid-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

  1. Add a tool. Write reverse_text (returns its input reversed): config class → @register_function → import in register.py → add to functions: and tool_names. Ask the agent to reverse something and watch it choose the right tool. Then delete the docstring and watch it stop choosing it.
  2. Trace the loop. Run with verbose: true on the workflow and read the raw Thought/Action/Observation turns.
  3. Break the protocol. Point config.yml at a small non-instruct model and observe ReAct parsing failures firsthand. Then run config-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.
  4. 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.