Build a flight search assistant
This tutorial builds a flight-search agent in Rust with
Rig. The agent finds flights between two airports by
calling a custom Tool that queries a flight-search API. It’s a
focused example of the pattern behind most agentic apps: define a Tool, register it with
an agent, and let the model decide when to call it.
When a user asks “find me flights from SAT to ATL next Friday”, the model reads the tool’s
description and parameter schema, replies with a tool call carrying JSON arguments, Rig runs
your call method, sends the result back to the model, and the model writes the final
answer from it.
You need an OpenAI API key and a RapidAPI key subscribed to the Tripadvisor flight-search API.
cargo new flight_search_assistantcd flight_search_assistantexport OPENAI_API_KEY=your_openai_api_keyexport RAPIDAPI_KEY=your_rapidapi_keyCargo.toml:
[dependencies]rig = "0.44.0"tokio = { version = "1", features = ["full"] }serde = { version = "1", features = ["derive"] }serde_json = "1"reqwest = { version = "0.13", features = ["json"] }thiserror = "2"chrono = "0.4"The flight search tool
Section titled “The flight search tool”Create src/flight_search_tool.rs. A Rig tool is a type that implements
Tool:
NAMEis the name the model calls the tool by.Argsis deserialized from the JSON arguments the model sends.Outputis what the model gets back; anythingSerializeworks, here aString.Erroris your own error type. Ifcallfails, the run keeps going: Rig sends the model a short failure notice as the tool result (the error’s details stay on your side, for logs and tracing), so the model can retry or explain.descriptionandparameters(a JSON Schema forArgs) are what the model sees when it decides whether and how to call the tool.
use chrono::{Duration, Utc};use rig::tool::{Tool, ToolContext};use serde::Deserialize;use serde_json::{json, Value};
#[derive(Deserialize)]pub struct FlightSearchArgs { source: String, destination: String, date: Option<String>,}
#[derive(Debug, thiserror::Error)]pub enum FlightSearchError { #[error("RAPIDAPI_KEY is not set")] MissingApiKey, #[error("flight search request failed: {0}")] Http(#[from] reqwest::Error), #[error("invalid request URL: {0}")] Url(String),}
pub struct FlightSearchTool;
impl Tool for FlightSearchTool { const NAME: &'static str = "search_flights";
type Args = FlightSearchArgs; type Output = String; type Error = FlightSearchError;
fn description(&self) -> String { "Search for one-way flights between two airports".to_string() }
fn parameters(&self) -> Value { json!({ "type": "object", "properties": { "source": { "type": "string", "description": "Departure airport IATA code, e.g. 'JFK'" }, "destination": { "type": "string", "description": "Arrival airport IATA code, e.g. 'LAX'" }, "date": { "type": "string", "description": "Flight date as YYYY-MM-DD; defaults to 30 days from today" } }, "required": ["source", "destination"] }) }
async fn call( &self, _ctx: &mut ToolContext, args: Self::Args, ) -> Result<Self::Output, Self::Error> { let api_key = std::env::var("RAPIDAPI_KEY").map_err(|_| FlightSearchError::MissingApiKey)?; let date = args .date .unwrap_or_else(|| (Utc::now() + Duration::days(30)).format("%Y-%m-%d").to_string());
let url = reqwest::Url::parse_with_params( "https://tripadvisor16.p.rapidapi.com/api/v1/flights/searchFlights", [ ("sourceAirportCode", args.source.as_str()), ("destinationAirportCode", args.destination.as_str()), ("date", date.as_str()), ("itineraryType", "ONE_WAY"), ("sortOrder", "PRICE"), ("numAdults", "1"), ("classOfService", "ECONOMY"), ("currencyCode", "USD"), ], ) .map_err(|e| FlightSearchError::Url(e.to_string()))?;
let data: Value = reqwest::Client::new() .get(url) .header("X-RapidAPI-Host", "tripadvisor16.p.rapidapi.com") .header("X-RapidAPI-Key", api_key) .send() .await? .error_for_status()? .json() .await?;
Ok(format_flights(&data)) }}
/// Turn the API response into a short list the model can read.fn format_flights(data: &Value) -> String { let flights = data["data"]["flights"].as_array().map(Vec::as_slice).unwrap_or_default(); let mut out = String::new(); for flight in flights.iter().take(5) { let legs = flight["segments"][0]["legs"].as_array().map(Vec::as_slice).unwrap_or_default(); let (Some(first), Some(last)) = (legs.first(), legs.last()) else { continue; }; out.push_str(&format!( "- {} {}{}: departs {} from {}, arrives {} at {}, {} stop(s), {} USD\n", first["marketingCarrier"]["displayName"].as_str().unwrap_or("Unknown airline"), first["marketingCarrierCode"].as_str().unwrap_or_default(), first["flightNumber"], first["departureDateTime"].as_str().unwrap_or("?"), first["originStationCode"].as_str().unwrap_or("?"), last["arrivalDateTime"].as_str().unwrap_or("?"), last["destinationStationCode"].as_str().unwrap_or("?"), legs.len() - 1, flight["purchaseLinks"][0]["totalPrice"], )); } if out.is_empty() { "No flights found for that route and date.".to_string() } else { out }}The response parsing is plain serde_json::Value indexing: a missing field yields
Value::Null instead of panicking, so a changed API response degrades to “Unknown” fields
rather than a crash. Returning a compact, readable list keeps the model’s context small;
it doesn’t need the raw API response.
Because FlightSearchTool is an ordinary type, you can unit test call directly without a
model.
The agent
Section titled “The agent”In src/main.rs, declare the module with mod flight_search_tool;, then build an agent
with the tool and prompt it:
use flight_search_tool::FlightSearchTool;use rig::prelude::*;use rig::providers::openai::{self, OpenAI};
#[tokio::main]async fn main() -> anyhow::Result<()> { let agent = AgentBuilder::new(OpenAI::from_env()?.completion(openai::GPT_5_5)) .preamble("You are a travel assistant. Use the search_flights tool to find flights, then summarize the best options.") .tool(FlightSearchTool) .default_max_turns(3) .build();
let response = agent .prompt("Find me flights from San Antonio (SAT) to Atlanta (ATL) next month.") .await?;
println!("{}", response.output()); Ok(())}AgentBuilder::newtakes the completion model;.tool(..)registers the tool so its definition is sent with every request..default_max_turns(3)lets the run go model → tool → model and still have room for one retry. Without enough turns, a run that needs a tool call stops withPromptError::MaxTurnsbefore the model writes its answer.response.output()is the final assistant text.response.messagesholds the whole exchange, including the tool call and its result, if you want to log what happened.
Add anyhow = "1" to Cargo.toml for the main error type, then run it:
cargo runHere are the cheapest flights from SAT to ATL on 2026-11-08:
1. Spirit NK123: departs 05:00, arrives 10:12, 1 stop, 77.97 USD2. American AA456: departs 18:40, arrives 23:58, 1 stop, 119.97 USD...Results depend on the live API.
Next steps
Section titled “Next steps”- Add parameters such as cabin class or number of passengers: extend
FlightSearchArgsand the JSON Schema together. - Derive the schema instead of writing it by hand, or skip the boilerplate with the
#[rig::rig_tool]macro; both are covered in Tools. - Wrap the agent in an interactive loop with Build a CLI chatbot.
See also
Section titled “See also”- Tools — the
Tooltrait and how agents call tools. - Agents — building and configuring agents.
- API Reference (docs.rs)
