Skip to content

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.

Terminal window
cargo new flight_search_assistant
cd flight_search_assistant
export OPENAI_API_KEY=your_openai_api_key
export RAPIDAPI_KEY=your_rapidapi_key

Cargo.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"

Create src/flight_search_tool.rs. A Rig tool is a type that implements Tool:

  • NAME is the name the model calls the tool by.
  • Args is deserialized from the JSON arguments the model sends.
  • Output is what the model gets back; anything Serialize works, here a String.
  • Error is your own error type. If call fails, 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.
  • description and parameters (a JSON Schema for Args) are what the model sees when it decides whether and how to call the tool.
src/flight_search_tool.rs
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.

In src/main.rs, declare the module with mod flight_search_tool;, then build an agent with the tool and prompt it:

src/main.rs
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::new takes 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 with PromptError::MaxTurns before the model writes its answer.
  • response.output() is the final assistant text. response.messages holds 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:

Terminal window
cargo run
Here 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 USD
2. American AA456: departs 18:40, arrives 23:58, 1 stop, 119.97 USD
...

Results depend on the live API.

  • Add parameters such as cabin class or number of passengers: extend FlightSearchArgs and 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.