Building AI agents in Rust makes the most sense when you care about production control: typed tools, predictable concurrency, lower memory use, and deployable single-binary services. If you only want to experiment, Python is still faster to prototype; if you want to operate agents seriously, Rust becomes much more compelling.
By Max Wells, updated August 2026
TL;DR: AI agents extend LLMs with tools, state, and loops: the model decides which tools to call, calls them, observes results, and continues until the goal is achieved. Building agents in Rust gives you predictable latency, type-safe tool definitions, and single-binary deployment; ideal for production agent infrastructure. This guide builds an agent from first principles, then shows the Rig framework shortcut.
- Agent loop: LLM call → parse tool calls → execute tools → feed results back → repeat until done
- Tool definition: JSON schema sent to the LLM describing each available function
- State: conversation history + tool call results accumulated across iterations
- Rig framework:
rig-corehandles the loop; focus on writing tools, not plumbing- Production patterns: timeouts, max iterations, structured output, error recovery
Who Should Read This?
This article is for backend engineers who want to understand the real mechanics of agent systems instead of only using high-level frameworks blindly.
This article is for software engineers who are building AI-powered backend systems and want to use Rust for the agent infrastructure layer rather than Python. If you are a senior backend engineer earning $155K–$220K in the US, already familiar with async Rust and REST APIs, and tasked with building a production-quality AI agent that will serve thousands of requests per day, this guide gives you the concrete implementation patterns you need. You should be comfortable with async/await and serde_json. No prior LLM API experience is assumed; tool calling is explained from first principles.
What Is an AI Agent?
An AI agent is an LLM in a loop with tools; it receives a goal, decides what tools to call, gets results, and keeps working until the goal is achieved or a stopping condition is hit.
The difference between a chatbot and an agent:
Chatbot (single turn):
User: "What's the weather in Paris?"
LLM: "I don't have real-time weather data."
Agent (multi-step):
User: "What's the weather in Paris?"
LLM: [decides to call get_weather tool]
Tool: {"city": "Paris", "temp": 18, "conditions": "Cloudy"}
LLM: "It's currently 18°C and cloudy in Paris."The agent loop:
Goal → [LLM] → Tool calls? ──yes──→ [Execute tools] → [LLM with results] → Done?
│ │
└─────────────────no──────────────────────────────────→ Final answerHow Do You Build an Agent Loop from Scratch?
The minimal agent is: (1) define tools as JSON schemas, (2) send them to the LLM with the user message, (3) if the LLM returns tool calls, execute them and loop, (4) if no tool calls, return the final answer.
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
// Tool definitions : sent to the LLM as JSON schema
fn get_tools() -> Vec<Value> {
vec![
json!({
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a city",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"}
},
"required": ["city"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "search_web",
"description": "Search the web for information",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"}
},
"required": ["query"]
}
}
}),
]
}
// Tool implementations
fn execute_tool(name: &str, args: &Value) -> String {
match name {
"get_weather" => {
let city = args["city"].as_str().unwrap_or("unknown");
// Real implementation would call a weather API
format!("{{'city': '{}', 'temperature': 22, 'conditions': 'Sunny'}}", city)
}
"search_web" => {
let query = args["query"].as_str().unwrap_or("");
// Real implementation would call a search API
format!("Search results for '{}': [result 1, result 2, result 3]", query)
}
_ => format!("Unknown tool: {}", name),
}
}
#[derive(Serialize, Deserialize)]
struct Message {
role: String,
content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_calls: Option<Vec<Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
}
async fn run_agent(goal: &str) -> Result<String, Box<dyn std::error::Error>> {
let client = reqwest::Client::new();
let api_key = std::env::var("OPENAI_API_KEY")?;
let mut messages: Vec<Value> = vec![
json!({"role": "system", "content": "You are a helpful assistant. Use tools when needed."}),
json!({"role": "user", "content": goal}),
];
let max_iterations = 10; // Safety limit
for iteration in 0..max_iterations {
// Call the LLM
let response = client
.post("https://api.openai.com/v1/chat/completions")
.bearer_auth(&api_key)
.json(&json!({
"model": "gpt-4o",
"messages": messages,
"tools": get_tools(),
"tool_choice": "auto",
}))
.send()
.await?
.json::<Value>()
.await?;
let choice = &response["choices"][0];
let message = &choice["message"];
let finish_reason = choice["finish_reason"].as_str().unwrap_or("");
// Add the assistant's response to history
messages.push(message.clone());
// If no tool calls : we're done
if finish_reason == "stop" || message["tool_calls"].is_null() {
let content = message["content"].as_str().unwrap_or("").to_string();
return Ok(content);
}
// Execute tool calls
let tool_calls = message["tool_calls"].as_array().unwrap();
for tool_call in tool_calls {
let tool_call_id = tool_call["id"].as_str().unwrap_or("");
let function_name = tool_call["function"]["name"].as_str().unwrap_or("");
let args_str = tool_call["function"]["arguments"].as_str().unwrap_or("{}");
let args: Value = serde_json::from_str(args_str)?;
println!("[Iteration {}] Calling tool: {}({:?})", iteration + 1, function_name, args);
let result = execute_tool(function_name, &args);
// Add tool result to message history
messages.push(json!({
"role": "tool",
"tool_call_id": tool_call_id,
"content": result,
}));
}
}
Err("Agent exceeded maximum iterations".into())
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let answer = run_agent("What's the weather in Tokyo and what are the top tourist attractions there?").await?;
println!("Agent answer:\n{}", answer);
Ok(())
}3 spots open this month → Check if you are eligible.
We help experienced developers transition into Rust roles at €80K–€150K+ in Europe or $130K–$200K+ in the US.
How Do You Build an Agent with Rig?
Rig abstracts the agent loop. Define your tools as Rust structs implementing Tool, and Rig handles the conversation, tool dispatch, and termination.
use rig::{completion::Prompt, providers::openai, tool::Tool};
use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Deserialize)]
struct WeatherArgs { city: String }
#[derive(Serialize)]
struct WeatherResult { city: String, temp_celsius: i32, conditions: String }
struct WeatherTool;
impl Tool for WeatherTool {
const NAME: &'static str = "get_weather";
type Error = String;
type Args = WeatherArgs;
type Output = WeatherResult;
fn definition(&self, _name: String) -> rig::tool::ToolDefinition {
rig::tool::ToolDefinition {
name: Self::NAME.to_string(),
description: "Get current weather for a city".to_string(),
parameters: json!({
"type": "object",
"properties": { "city": {"type": "string"} },
"required": ["city"]
}),
}
}
async fn call(&self, args: WeatherArgs) -> Result<WeatherResult, String> {
Ok(WeatherResult {
city: args.city,
temp_celsius: 22,
conditions: "Sunny".to_string(),
})
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let openai = openai::Client::from_env();
let agent = openai
.agent(openai::GPT_4O)
.preamble("You are a helpful assistant. Use tools when needed to answer questions.")
.tool(WeatherTool)
.build();
let answer = agent
.prompt("What's the weather in Paris and Tokyo?")
.await?;
println!("{}", answer);
Ok(())
}How Do You Add Memory to an Agent?
Agent memory stores past interactions or retrieved facts. Short-term (conversation history) and long-term (vector store retrieval) are the two standard patterns.
use std::collections::VecDeque;
// Short-term memory: sliding window conversation history
struct AgentMemory {
history: VecDeque<(String, String)>, // (role, content) pairs
max_turns: usize,
}
impl AgentMemory {
fn new(max_turns: usize) -> Self {
Self { history: VecDeque::new(), max_turns }
}
fn add(&mut self, role: &str, content: &str) {
if self.history.len() >= self.max_turns * 2 {
self.history.pop_front();
self.history.pop_front();
}
self.history.push_back((role.to_string(), content.to_string()));
}
fn to_messages(&self) -> Vec<serde_json::Value> {
self.history.iter()
.map(|(role, content)| serde_json::json!({
"role": role,
"content": content
}))
.collect()
}
}For long-term memory, combine with a vector database (Qdrant); embed each interaction, store it, and retrieve relevant past interactions at query time.
If you want the local-model side of this stack too, Using Ollama with Rust: Local LLM Integration Guide is the natural companion article.
How Do You Handle Agent Observability and Tracing?
Production agents need structured logging of every LLM call, tool invocation, and decision. The tracing crate integrates naturally with the agent loop.
use tracing::{info, warn, instrument};
#[instrument(skip(api_key), fields(model = "gpt-4o", iteration))]
async fn run_agent_iteration(
messages: &[Value],
api_key: &str,
iteration: usize,
) -> Result<Value, Box<dyn std::error::Error>> {
info!(message_count = messages.len(), "Starting LLM call");
let response = /* ... LLM API call ... */ todo!();
info!(
finish_reason = response["choices"][0]["finish_reason"].as_str(),
"LLM responded"
);
Ok(response)
}
// In your tool execution:
fn execute_tool_with_tracing(name: &str, args: &Value) -> String {
let span = tracing::info_span!("tool_call", tool = name);
let _guard = span.enter();
info!(args = %args, "Executing tool");
let result = execute_tool(name, args);
info!(result_len = result.len(), "Tool completed");
result
}Pairing this with tracing-subscriber that writes to your observability platform (Datadog, Honeycomb, Grafana) gives you full visibility into agent decision-making: which tools were called, how many iterations a goal required, and where agents fail or time out.
How Do You Deploy a Rust AI Agent to Production?
A Rust AI agent compiles to a single binary, making deployment straightforward. Containerize with a minimal base image and deploy behind an API gateway or as a serverless function.
Production deployment topology:
─────────────────────────────────────────────────────────
Client request
→ API Gateway (rate limiting, auth)
→ Rust Agent Service (single binary, ~10MB Docker image)
→ OpenAI/Anthropic API (LLM calls)
→ Tool services (weather API, search API, database)
→ Redis (conversation memory store)
→ ResponseKey production considerations:
- Rate limiting: LLM APIs have per-minute token limits. Use a Tokio semaphore to bound concurrent LLM calls.
- Cost tracking: Log token usage from every LLM response (
usage.total_tokens) to a metrics system. - Timeout budget: Set a total wall-clock timeout per agent run; a multi-step agent that takes 30 seconds frustrates users. 10–15 seconds is a practical limit for user-facing agents.
- Retry with backoff: LLM APIs return 429 rate limit errors. Implement exponential backoff with jitter using the
backoffcrate.
Bottom line: Every production agent needs three hard limits: a
max_iterationsceiling (10–20), a per-iteration timeout, and a total wall-clock budget. Without all three, a poorly-specified goal or broken tool can run indefinitely, burning LLM API credits and producing no output.
Senior Rust AI engineers who build and operate production agent infrastructure at US companies currently earn $175K–$240K. The combination of Rust systems expertise and LLM API knowledge is uncommon enough to command a premium.
Bottom line: Rust AI agents compile to single static binaries under 10 MB with cold starts under 5ms. Python agent frameworks typically ship 200–400 MB containers with 1–3s startup. For latency-sensitive or serverless deployments, the gap is decisive.
What Common Mistakes Do Developers Make When Building Rust AI Agents?
The most costly mistake is not setting a maximum iteration limit. Without it, a poorly-specified goal or a tool that always returns unhelpful results causes an agent to run indefinitely, burning LLM API credits and producing no useful output.
-
No iteration ceiling: Every agent loop must have a hard
max_iterationslimit (typically 10–20 for user-facing agents) and an overall timeout. An agent that hits the limit should return a partial result or a clear error, not hang indefinitely. Engineers who skip this discover the problem via unexpected API bills. -
Ignoring token budget in conversation history: Conversation history grows with each iteration. At 10 iterations with verbose tool results, the message history may exceed the model's context window, causing truncation or errors. Track token count and summarize or truncate history before each LLM call when it approaches the limit.
-
Over-engineering tools: Tools should be narrow and single-purpose:
get_weather(city: String), notget_all_information(query: String). Broad tools confuse the LLM about when to use them. Write tools that do one thing and describe that one thing clearly in their JSON schema description. -
Trusting tool output without validation: Tool results are strings fed back to the LLM. If a tool returns unexpected data (API failure, malformed response), the LLM may hallucinate based on the bad input. Validate tool outputs before adding them to the message history, and return structured error messages rather than empty strings.
-
Missing structured output for final answers: For agents that return data (not just text), use OpenAI's
response_formator Anthropic's tool-based structured output to get JSON back from the LLM rather than parsing natural language text. Parsing free text for structured data is fragile and unnecessary. -
Not testing with adversarial goals: Agent behavior is hard to predict. Before deploying, test with edge cases: goals that require 0 tool calls, goals that require the maximum iterations, goals that reference non-existent tools, and goals that trigger conflicting tool results. Agents often fail in these edge cases during testing rather than in production.
Want a Structured Learning Path for Rust AI Development?
Building production AI agents in Rust requires strong foundations in async Rust, error handling, and API integration patterns. If you want a guided path from Rust basics to building production-quality backend systems, Rustify's 9-week bootcamp covers async Rust, HTTP clients, and real-world project patterns with 1:1 coaching. The program is designed for engineers who want to move fast without the false starts that come from learning in isolation.
Frequently Asked Questions
RAG (Retrieval-Augmented Generation) is a single-step process: retrieve relevant documents, include them in the prompt, get one answer. An agent is a multi-step loop: the LLM decides what to do next based on tool results, potentially calling multiple tools in sequence, adapting based on what it finds. RAG answers factual questions; agents execute tasks that require planning and multiple steps.
Always set a max_iterations limit and a per-call timeout. Add a finished tool that the agent should call when it's done, or use finish_reason == "stop" as the termination signal. For production agents, also set a maximum total token budget.
Modern LLMs (GPT-4o, Claude 3.5+) handle tool calling natively via the tools parameter; this is structured, reliable JSON output. Don't parse tool calls from free text. Use the official function calling API: it's more reliable and the model is specifically trained to use it correctly.
Feed the error back to the LLM as a tool result; let the model decide whether to retry, try a different approach, or ask for clarification. Most LLMs handle tool errors gracefully and will attempt alternatives when told a tool failed.
Python has more LLM ecosystem tooling (LangChain, LlamaIndex, CrewAI), but Rust agents are more efficient: lower memory, predictable latency, no GIL for concurrent tool execution, and a single-binary deployment. For high-volume production agents where you pay per compute second, Rust's efficiency pays off. For prototyping and experimentation, Python remains faster to iterate.
The two APIs have slightly different schemas for tool definitions and tool call responses. Rig abstracts this with provider-specific client implementations. If you build the raw loop yourself, use an enum or trait abstraction over the provider, and write a small adapter layer for each API's tool calling format. The concepts are identical; the JSON field names differ.
Engineers who specialize in production AI infrastructure with Rust, building the agent runtimes, tool execution layers, and observability systems, typically earn $165K–$240K total compensation at well-funded US startups and tech companies in 2026. The combination of Rust systems proficiency and LLM API experience is rare and in high demand as companies move AI capabilities from prototype to production.
Yes. Rig supports multiple LLM providers including Anthropic Claude, OpenAI, Cohere, and others via provider-specific client implementations. Switching providers is a one-line change in the agent builder: replace openai::Client::from_env() with anthropic::Client::from_env(). Tool definitions use the same Rust trait regardless of provider.
Keep Reading
- Using Ollama with Rust: Local LLM Integration Guide
- VC-Funded Rust Startups to Know in 2026
- Serde in Rust: The Complete Serialization Guide
- Rust on AWS Lambda: The Complete Serverless Guide
