Skip to content

2.2 Recipe: The AI Travel Planner Suite

In this recipe, we’re going to build a production-ready feature: an AI Travel Planner Suite. This suite will consist of multiple specialized flows that work together to provide a seamless travel planning experience.

The idea is to demonstrate how to:

  • The power of flow orchestration: building complex features by composing smaller flows. This promotes reusability and maintainability.
  • Use structured data outputs with Zod schemas for reliable frontend integration.

    Structured outputs ensure that the data returned from LLMs is predictable and easy to work with. From processing results to rendering UI components, structured data is a must-have for building robust applications instead of simple chatbots that are synonymous with GenAI Apps.

  • Implement streaming responses for long-form content generation.

For this example, we’ll create four distinct flows:

  • An explorer flow that suggests travel destinations based on user preferences. We will pass the user’s vibe and budget and get back structured JSON data.

  • An itinerary builder that generates detailed day-by-day plans. This flow will stream the response back to the client for a better user experience.

  • A packing assistant that creates customized packing lists. This flow will also return structured data.

  • A master “lucky planner” flow that orchestrates the other three to deliver a complete travel package.

    Diagram

For testing purposes, we are going to use the Genkit Developer UI to test our flows. We can also use CURL or Postman to make HTTP requests to our flow server. I will be adding supplementary recipes on how to build a frontend to consume these flows later on.

You should have your environment set up for Genkit development. Follow the Getting Started guide if you haven’t done so already.

I have a Genkit Starter Template that you can use to quickly spin up a new project with everything configured. You can find it here.

First, let’s first ensure we have the necessary packages installed. We’ll need the @genkit-ai/googleai plugin for LLM access and @genkit-ai/express to serve our flows over HTTP.

Terminal window
npm install @genkit-ai/googleai @genkit-ai/express

If you are curious about using other LLM providers, check out the Genkit AI Plugins documentation. There are many to choose from!

We will start by defining our first flow: the Destination Explorer. This flow will suggest travel destinations based on user preferences. It will return structured JSON data, so we need to define our input and output schemas using Zod.

When prompting LLMs, you can request for structured outputs like JSON. However, to ensure the output is always valid and parsable, it’s best to enforce the schema at the LLM level as well as in your flow definition. the ai.generate method in Genkit allows you to do just that, by passing an output parameter with the desired schema.

For this flow, we will define the shape of input and output data as follows:

// Please note we are importing z from 'genkit' to ensure compatibility with Genkit's internal handling of schemas.
import { z } from 'genkit';
// Define our shared suggestion schema
const explorerFlowInputSchema = z.object({
vibe: z.enum(['adventure', 'relaxation', 'culture', 'foodie', 'nightlife']),
budget: z.enum(['cheap', 'moderate', 'luxury']),
});
// Define our explorer flow output schema
const explorerFlowOutputSchema = z.object({
suggestions: z.array(
z.object({
city: z.string().describe('Name of the city'),
country: z.string().describe('Name of the country'),
reason: z.string().describe('Reason for recommendation'),
}),
),
});

Next, then we can now define our explorer flow using these schemas. As each flow is an instance of Genkit, first, we need to create an AI instance.

import { genkit, z } from 'genkit';
const ai = genkit({
plugins: [googleAI()],
model: googleAI.model('gemini-2.5-flash'),
});

And now we can define our explorer flow.

const explorerFlow = ai.defineFlow(
{
name: 'destinationExplorer',
inputSchema: explorerFlowInputSchema,
outputSchema: explorerFlowOutputSchema,
},
async ({ vibe, budget }) => {
// ... code to call the LLM and return structured suggestions
},
);

If you have followed along, your full code should look like this so far:

import { genkit, z } from 'genkit';
import { googleAI } from '@genkit-ai/googleai';
const ai = genkit({
plugins: [googleAI()],
model: googleAI.model('gemini-2.5-flash'),
});
// Define our shared suggestion schema
const explorerFlowInputSchema = z.object({
vibe: z.enum(['adventure', 'relaxation', 'culture', 'foodie', 'nightlife']),
budget: z.enum(['cheap', 'moderate', 'luxury']),
});
// Define our explorer flow output schema
const explorerFlowOutputSchema = z.object({
suggestions: z.array(
z.object({
city: z.string().describe('Name of the city'),
country: z.string().describe('Name of the country'),
reason: z.string().describe('Reason for recommendation'),
}),
),
});
const explorerFlow = ai.defineFlow(
{
name: 'destinationExplorer',
inputSchema: explorerFlowInputSchema,
outputSchema: explorerFlowOutputSchema,
},
async (input) => {
// ... LOGIC TO CALL LLM GOES HERE
},
);

Next, let’s implement the logic to call the LLM and return structured suggestions. This is done inside the flow handler function, and it’s where we use the ai.generate method to interact with the LLM. As we will see later, we can perform other operations here as well, such as calling other flows or external APIs.

const explorerFlow = ai.defineFlow(
{
name: 'destinationExplorer',
inputSchema: explorerFlowInputSchema,
outputSchema: explorerFlowOutputSchema,
},
// input type is inferred from inputSchema
async (input) => {
// 1. Call the LLM with structured output enforcement, using the input parameters
const { output } = await ai.generate({
prompt: `
You are a travel expert. Suggest 5 travel destinations based on the following preferences:
Vibe: ${input.vibe}
Budget: ${input.budget}
`,
// Enforce the schema at the LLM level for perfect JSON output
output: { schema: explorerFlowOutputSchema },
});
// 2. Throw an error if output is missing, sometimes LLM calls can fail silently
if (!output) {
throw new Error('Failed to generate suggestions');
}
// 3. Return the structured output
return output;
},
);

And that’s our explorer flow done! Next, let’s build the other flows.

The Itinerary Builder flow will generate a detailed day-by-day itinerary based on the selected destination and trip duration. This flow will stream the response back to the client for a better user experience.

First, let’s define the input schema for this flow:

const itineraryFlowInputSchema = z.object({
destination: z.string(),
durationDays: z.number().min(1).max(7),
});

We won’t define a complex output schema for this flow since it will return raw Markdown text.

Next, let’s define the itinerary flow. Just like before, we use the ai.defineFlow method to create the flow. However, this time, we will use the ai.generateStream method to enable streaming, instead of the regular ai.generate.

const itineraryFlow = ai.defineFlow(
{
name: 'itineraryBuilder',
inputSchema: itineraryFlowInputSchema,
outputSchema: z.string(), // We return raw Markdown text
},
// input type is inferred from inputSchema
async (input, { sendChunk }) => {
// 1. Use streaming generation for the itinerary
const { response, stream } = ai.generateStream({
prompt: `
You are an expert travel planner. Your goal is to create detailed itineraries for travelers.
Create a detailed ${input.durationDays}-day itinerary for ${input.destination}.
Use Markdown formatting. Include timings and specific restaurants.
`,
});
// 2. Stream the response back to the client
for await (const chunk of stream) {
sendChunk(chunk.text); // Stream partial text to client
}
// 3. Return the full text at the end
return (await response).text;
},
);

And that’s our itinerary flow done! Next, let’s build the packing assistant flow.

The Packing Assistant flow will create a customized packing list based on the destination, duration, and activities planned. This flow will also return structured data.

First, let’s define the input and output schemas for this flow:

const packingFlowInputSchema = z.object({
destination: z.string(),
season: z.enum(['spring', 'summer', 'fall', 'winter']),
});
const packingFlowOutputSchema = z.object({
essentials: z.array(z.string()).describe('Essential items to pack'),
clothing: z.array(z.string()).describe('Clothing items to pack'),
gadgets: z.array(z.string()).describe('Gadgets to bring along'),
});

Next, let’s define the packing flow using these schemas:

const packingFlow = ai.defineFlow(
{
name: 'packingAssistant',
inputSchema: packingFlowInputSchema,
outputSchema: packingFlowOutputSchema,
},
async (input) => {
const { output } = await ai.generate({
prompt: `
You are a packing expert. Your goal is to help travelers pack efficiently for their trips.
For each trip, provide a categorized packing list with essentials, clothing, and gadgets. Consider the destination and season.
Create a packing list for ${input.destination} in ${input.season}.
`,
output: { schema: packingFlowOutputSchema },
});
if (!output) {
throw new Error('Failed to generate packing list');
}
return output;
},
);

And that’s our packing assistant flow done! Finally, let’s build the master flow that orchestrates all these flows.

Step 4: Define the Lucky Planner Master Flow

Section titled “Step 4: Define the Lucky Planner Master Flow”

The idea behind the Lucky Planner flow is to provide a one-stop solution for users to get a complete travel package. This flow will orchestrate the other three flows to deliver destination suggestions, a detailed itinerary, and a packing list.

Diagram

First, let’s define the input and output schemas for the Lucky Planner flow:

const iAmFeelingLuckyFlowInputSchema = z.object({
vibe: z.enum(['adventure', 'relaxation', 'culture', 'foodie', 'nightlife']),
budget: z.enum(['cheap', 'moderate', 'luxury']),
season: z.enum(['spring', 'summer', 'fall', 'winter']),
durationDays: z.number().min(1).max(7),
});
const iAmFeelingLuckyFlowOutputSchema = z.object({
destination: z.string(),
reason: z.string(),
itinerary: z.string(),
packingList: packingFlowOutputSchema,
});

And then we can define the Lucky Planner flow itself. Please note how we call the other flows within this flow handler function and since flows are just async functions, we can call them like regular functions, that return promises.

const iAmFeelingLuckyFlow = ai.defineFlow(
{
name: 'iAmFeelingLuckyPlanner',
inputSchema: iAmFeelingLuckyFlowInputSchema,
outputSchema: iAmFeelingLuckyFlowOutputSchema,
},
async (input) => {
// 1. Orchestrate: Call the explorer flow to get options
const { suggestions } = await explorerFlow({
vibe: input.vibe,
budget: input.budget,
});
// if no suggestions, throw an error
if (!suggestions || suggestions.length === 0) {
throw new Error('No suggestions found');
}
// 2. Pick the attractive option (here, we just take the first)
const selection = suggestions[0];
const destination = selection.city;
// 3. Parallel Execution: Run itinerary and packing flows together
const [itinerary, packingList] = await Promise.all([
itineraryFlow({ destination, durationDays: input.durationDays }),
packingFlow({ destination, season: input.season }),
]);
// 4. Return the combined result
return {
destination: selection.city,
reason: selection.reason,
itinerary,
packingList,
};
},
);

And that’s our Lucky Planner flow done! We now have a complete suite of flows that work together to provide a seamless travel planning experience.

Step 5: Testing the Flows using Genkit Developer UI

Section titled “Step 5: Testing the Flows using Genkit Developer UI”

First, we need to start the Genkit Developer UI. If you followed the Getting Started guide, you should have it installed globally and your project set up correctly. If not, please refer to the guide.

Please ensure you have your GENKIT_API_KEY environment variable set up with your Genkit API key or in your .env file, if you are using our starter kit.

Next, we need to run the Genkit Developer UI in our project directory, like so:

Terminal window
npx genkit start -o -- npx tsx ./src/index.ts

This command does two things:

  • It starts the Genkit Developer UI and opens it in your default web browser.
  • It runs your Genkit project using tsx, which allows us to use TypeScript directly without compiling it first.

    Make sure to replace ./src/index.ts with the actual path to your project entry file if it’s different.

Once the Developer UI is running, you should see your defined flows listed in the sidebar. You can click on each flow to test them individually by providing the required inputs.

In the current setup, our flows are defined but not yet accessible over HTTP, so they can’t be called from a frontend or external application. To make our flows accessible, we need to set up an Express server using the @genkit-ai/express plugin. And then we can use the startFlowServer function to serve our flows, like so:

import { startFlowServer } from '@genkit-ai/express';
// Our existing flow definitions go here...
startFlowServer({
// Register all our flows here
flows: [explorerFlow, itineraryFlow, packingFlow, iAmFeelingLuckyFlow],
// Specify the port to run the server on
port: 3000,
// Enable CORS for cross-origin requests
cors: true,
});

And that’s it! You now have a robust multi-flow backend built with Genkit, featuring structured outputs, streaming, and flow orchestration. You can now call these flows from your frontend application or any HTTP client.

Terminal window
# Test the Destination Explorer flow on localhost:3000, using CURL
curl --request POST \
--url http://localhost:3000/destinationExplorer \
--header 'content-type: application/json' \
--data '{
"data": {
"vibe": "adventure",
"budget": "cheap"
}
}'
# Test the Itinerary Builder flow on localhost:3000, using CURL
curl --request POST \
--url http://localhost:3000/itineraryBuilder \
--header 'content-type: application/json' \
--data '{
"data": {
"destination": "Nairobi",
"durationDays": 5
}
}'
# Test the Packing Assistant flow on localhost:3000, using CURL
curl --request POST \
--url http://localhost:3000/packingAssistant \
--header 'content-type: application/json' \
--data '{
"data": {
"destination": "Nairobi",
"season": "summer"
}
}'
# Test the Lucky Planner flow on localhost:3000, using CURL
curl --request POST \
--url http://localhost:3000/iAmFeelingLuckyPlanner \
--header 'content-type: application/json' \
--data '{
"data": {
"vibe": "adventure",
"budget": "cheap",
"season": "summer",
"durationDays": 5
}
}'

In this recipe, we built a comprehensive AI Travel Planner Suite using Genkit. We demonstrated how to define multiple specialized flows, enforce structured data outputs, implement streaming responses, and orchestrate flows to create complex features. This approach promotes modularity, reusability, and maintainability in your Genkit applications.

To further enhance this suite, consider adding more features such as:

  • Integrating real-time data sources (e.g., weather, events) into the itinerary. TIP: You can call external APIs from within your flow handler functions to fetch real-time data.
  • Adding user preferences for activities and accommodations.

🚀 Get the Complete Code

This recipe includes a full GitHub repository with working code, setup instructions, and examples. Unlock access to build faster and support this cookbook!

Complete, tested code repositories
Copy-paste ready examples
Lifetime access with updates
Support future recipes & content