The Rig implementation exists for one reason: to implement the same
production-agent architecture with a thinner framework, so that what an agent
runtime actually does — the loop, tool dispatch, policy, streaming, MCP,
suspension for a human — is visible as ordinary code instead of toolkit
configuration. It is not a replacement for NAT, and this page does not pick a
winner. The question it answers is:
What changes when the same architecture is implemented using a high-level
agent toolkit versus a thinner Rust framework?
same architecture browser → assistant-ui → gateway → AGENT → MCP → PostgreSQL
same trust model the model proposes; deterministic software decides
same application support-ticket triage, same seed data
same MCP boundary the same Rust MCP server, tools and approval verifier
same gateway the same OIDC, sessions, CSRF and identity headers
same contract AGENT-SERVICE-CONTRACT.md — routes, SSE, errors, provenance
same prompts byte-identical system and guardrail prompts (equal prompt_sha256)
same evaluation the same datasets, scorers and gates
different agent runtime NAT + Python + NeMo Guardrails vs Rig + Rust + explicit policy
Everything outside agent/ is identical on the two branches, apart from the
build files that run the agent (Compose service, Makefile targets, CI job) and
the checks that inspect agent source. The documentation is identical too: it
lives on main and describes both.
NAT's ReAct graph on LangGraph, wrapped by register.py to stream native tool-calling answers; bounded by a recursion limit derived from max_tool_calls
Rig
Rig's AgentRunner over a serialisable AgentRun state machine, built per request in builder.rs and driven in execution.rs; bounded by a model-call budget and a deterministic tool-call budget
Both frameworks own the loop. Rig exposes it as a small state machine you can read; NAT hides it behind a configurable graph. The loop is never where authority is decided.
rmcp 2.2 (the server's SDK line) through rig-rmcp's McpTool (client.rs); allow-list and overrides from config; tool schemas outside an enforceable subset, or declaring identity/approval fields, stop startup
Relation
equivalent boundary; stricter discovery
Takeaway
MCP stays the capability boundary in both. Neither agent touches the database.
Arguments parsed into pydantic models derived from the MCP schema; the tools that exist are whatever NAT was configured with
Rig
Every model-proposed call passes Rig's AgentHook::on_dispatch (hooks.rs), which asks one pure function, ToolPolicy::decide: closed registry, JSON object only, no unknown fields, no coercion ("5" is not an integer), mutations only into the approval gate; the executors re-check the same policy
Relation
stricter
Takeaway
"The model proposes; deterministic software authorises" is a single, testable function on Rig, and is distributed across configuration and framework parsing on NAT. In both, the MCP server re-validates and stays authoritative.
NAT middleware (text_guardrails), a custom FastAPI worker, a substituted execution store, telemetry processors
Rig
Rig AgentHook for tool policy; everything else is the service's own Axum middleware and plain functions
Relation
different
Takeaway
NAT's extension points let you change behaviour without owning the loop; Rig's let you own the loop without patching anything. On main several modules exist to work around specific toolkit behaviour (see EXTENDING.md); rust-agent has none.
Service key, then exactly one x-authenticated-user-id, enforced by ASGI middleware in fastapi_worker.py because NAT's own refusal does not reach the client on the workflow routes
The same patterns, templates, precedence and prompt as pure functions (guardrails/input.rs); one classifier call parsed exactly as NeMo 0.21's is_content_safe
Relation
equivalent; Rig fails closed (refuses) when the classifier is unreachable
Takeaway
The only probabilistic part is the classifier's reply; how it is read and combined is deterministic on both.
NeMo regex rail blocks credentials on a rolling window of chunks; Presidio masks PII, which forces the whole answer to be buffered first
Rig
The same secret patterns in a bounded streaming window (320 characters held back, 512 of look-behind); deterministic PII recognisers (regex + Luhn, IBAN mod-97, Base58Check, Bech32) masked inside the same window (output.rs, pii.rs)
Relation
secrets equivalent; PII narrower; latency different
Takeaway
This is the clearest trade-off in the comparison, and it is not parity. Presidio combines patterns, context and a spaCy NER model; the Rust recognisers are stricter where an entity has a checksum and miss more where it does not (international phone layouts, anything found only through context). What the narrowing buys is a model-free output path that keeps streaming.
An explicit enum with legal transitions (state.rs): Received → ScreeningInput → Running ⇄ AwaitingApproval → Completed / Failed / Cancelled, ScreeningInput → Blocked
Relation
different
Takeaway
Making invalid states unrepresentable — a blocked request cannot await approval, a finished run cannot resume — is cheap in Rust and worth copying into any runtime's design, even when the framework hides its own state.
equivalent security properties; stricter lifecycle (prompts expire; partial enablement refuses to start; the model never sees actor_id)
Takeaway
Same token, same MCP verifier, same single-use transaction. The difference is where the checks live: patched into a toolkit's store, or built into the type the run waits on.
In memory: a paused coroutine holding the client's stream
Rig
In memory: an awaiting task holding the client's stream; Rig's AgentRun is serialisable, which is the piece a durable design would build on (not implemented)
Relation
equivalent (both lose pending approvals on restart)
Takeaway
Durable suspension needs more than a serialisable run: a reconnectable stream and a pending-record store. Part II of the curriculum covers it.
NAT's exporter and NeMo Guardrails' OTel spans, joined into one trace by pre-seeding NAT's root span through private NAT attributes
Rig
tracing + tracing-opentelemetry; Rig adopts the request's root span, so its chat and execute_tool spans nest under it; the export filter is fixed in code because Rig logs whole provider requests at TRACE
Relation
equivalent goal (one trace per request); different mechanism
Takeaway
One tracing model in one process removes the hardest problem NAT's observability package solves.
Single runs on a local stack (qwen3:8b on Ollama, both models the same) —
observations, not benchmarks:
Rig (rust-agent)
"Show me the open support tickets"
search_tickets{status: open}, 15.6 s through the UI
"Which ticket should we handle first, and why?"
one search_tickets call, TKT-1002 chosen, 3.2 s
"Summarize ticket TKT-1003 and its history"
get_ticket{TKT-1003}, grounded, 8.4 s
Input-rail classifier call
0.2–2.3 s
make eval-all
all four suites at their gates (1.0)
Agent image
71 MB (NAT: 2.01 GB)
CONFIGURATION.md
records that qwen3:8b struggles with the prioritisation prompt on NAT; on Rig
it answered from search_tickets alone in the runs observed. One run is not
evidence that the runtime caused it: the model and prompt are the same, but the
two clients build different request bodies (tool-schema rendering, message
structure).
NAT when you want breadth now: managed guardrail flows with model-based
checks, Presidio's NER, a plugin ecosystem, profiling and evaluation
integrations, and a Python team. You pay in dependency weight and in
behaviour that lives in framework internals — main needs compatibility
shims and private-attribute access to get one trace.
Rig when you want the loop as a library inside code you own: a small
dependency surface, compile-time-checked state, a single binary, and every
security decision as ordinary code. You pay by writing what NAT gives you,
and by tracking a younger framework whose API moved substantially between
minor versions.
Either way, the boundary that matters most does not move: the MCP server, not
the agent, decides what may change in the database.