DEV Community

Lacey Glenn
Lacey Glenn

Posted on

Build an AI Trip Planner With an LLM API and Structured JSON Output

---
title: "Build an AI Trip Planner With an LLM API and Structured JSON Output"
published: false
description: "Turn a free-form travel request into a validated, typed itinerary your app can render, using tool calling, a JSON schema and a retry loop."
tags: ai, typescript, node, tutorial
series: Building a Travel App From Scratch
---
Enter fullscreen mode Exit fullscreen mode

Build an AI Trip Planner With an LLM API and Structured JSON Output

Ask an LLM for a three-day Lisbon itinerary and you get a lovely paragraph. Ask your app to display that paragraph as a card per day, with times and costs, and you have a problem. Free text is for humans. Apps need data.

In this post we'll build a small trip planner backend that returns a validated, typed itinerary every time. We'll cover:

  1. Forcing the model to return JSON that matches a schema
  2. Validating the result with Zod
  3. Checking business rules the schema can't express (budget, day count)
  4. Retrying with feedback when the output is wrong
  5. Exposing it through an Express endpoint

Stack: Node 20+, TypeScript, Express, Zod and the Anthropic SDK. The ideas work with any provider that supports tool calling or a JSON schema mode.

Why not just say "respond in JSON"?

You can ask nicely in the prompt, and it will often work. In production, "often" means a broken screen for some users: markdown fences around the JSON, a missing field, a string where you expected a number, or a cheerful sentence before the opening brace.

There are two more reliable approaches:

  • Tool calling with a forced tool. You describe a function with a JSON schema, and tell the model it must call it. The arguments arrive as structured data instead of prose.
  • Native JSON schema output modes, where the provider supports them.

We'll use forced tool calling, then validate anyway. A schema constrains the model, but your own validation is what protects your app.

Project setup

mkdir ai-trip-planner && cd ai-trip-planner
npm init -y
npm i express zod @anthropic-ai/sdk
npm i -D typescript tsx @types/node @types/express
npx tsc --init
export ANTHROPIC_API_KEY="your-key-here"
Enter fullscreen mode Exit fullscreen mode

Never commit your key. Use environment variables or a secrets manager.

Step 1: Define the shape of a trip

Start with the output you want, not the prompt. Here's the Zod schema:

// src/schema.ts
import { z } from "zod";

export const ActivitySchema = z.object({
  time: z.string().regex(/^\d{2}:\d{2}$/, "Use 24h HH:MM"),
  title: z.string().min(1),
  description: z.string().min(1),
  category: z.enum(["food", "sightseeing", "culture", "outdoors", "transport", "rest"]),
  estimatedCostUsd: z.number().min(0),
});

export const DaySchema = z.object({
  day: z.number().int().min(1),
  theme: z.string().min(1),
  activities: z.array(ActivitySchema).min(1),
});

export const ItinerarySchema = z.object({
  destination: z.string().min(1),
  days: z.array(DaySchema).min(1),
  packingTips: z.array(z.string()).max(6),
});

export type Itinerary = z.infer<typeof ItinerarySchema>;

export const TripRequestSchema = z.object({
  destination: z.string().min(2).max(80),
  days: z.number().int().min(1).max(14),
  budgetUsd: z.number().positive(),
  interests: z.array(z.string().max(40)).max(5).default([]),
});

export type TripRequest = z.infer<typeof TripRequestSchema>;
Enter fullscreen mode Exit fullscreen mode

Notice what's missing: a totalCost field. Don't make an LLM do arithmetic you can do in code. Models are decent at it but not reliable, so we'll compute totals ourselves later. Every field you remove is one less thing that can be wrong.

Step 2: Describe the same shape as a tool

The model needs the schema in JSON Schema form. You can generate it from Zod with a helper library, or write it by hand as here:

// src/tool.ts
import type Anthropic from "@anthropic-ai/sdk";

export const itineraryTool: Anthropic.Tool = {
  name: "create_itinerary",
  description: "Return a complete day-by-day travel itinerary for the requested trip.",
  input_schema: {
    type: "object",
    properties: {
      destination: { type: "string" },
      days: {
        type: "array",
        items: {
          type: "object",
          properties: {
            day: { type: "integer", minimum: 1 },
            theme: { type: "string" },
            activities: {
              type: "array",
              items: {
                type: "object",
                properties: {
                  time: { type: "string", description: "24h time, HH:MM" },
                  title: { type: "string" },
                  description: { type: "string" },
                  category: {
                    type: "string",
                    enum: ["food", "sightseeing", "culture", "outdoors", "transport", "rest"],
                  },
                  estimatedCostUsd: { type: "number", minimum: 0 },
                },
                required: ["time", "title", "description", "category", "estimatedCostUsd"],
              },
            },
          },
          required: ["day", "theme", "activities"],
        },
      },
      packingTips: { type: "array", items: { type: "string" } },
    },
    required: ["destination", "days", "packingTips"],
  },
};
Enter fullscreen mode Exit fullscreen mode

Step 3: Call the model and force the tool

// src/planner.ts
import Anthropic from "@anthropic-ai/sdk";
import { ItinerarySchema, type Itinerary, type TripRequest } from "./schema";
import { itineraryTool } from "./tool";

const client = new Anthropic(); // reads ANTHROPIC_API_KEY
const MODEL = process.env.LLM_MODEL ?? "claude-sonnet-5"; // check current model names in the docs

const SYSTEM = `You are a careful travel planner.
- Plan realistic days: group nearby places, leave time to travel and rest.
- Use estimated costs in USD per person for each activity.
- Only suggest well-known, real places. If unsure a place exists, choose something else.
- Never include booking links or claim live availability or prices.`;

function buildPrompt(req: TripRequest, feedback?: string[]) {
  let prompt =
    `Plan a ${req.days}-day trip to ${req.destination}.\n` +
    `Total activity budget per person: $${req.budgetUsd} USD.\n` +
    `Interests: ${req.interests.join(", ") || "general sightseeing"}.\n` +
    `Return exactly ${req.days} days.`;
  if (feedback?.length) {
    prompt += `\n\nYour previous attempt had problems. Fix all of them:\n- ${feedback.join("\n- ")}`;
  }
  return prompt;
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Validate schema and business rules

A schema can say "this is a number." It can't say "the days must match what the user asked for" or "stay under budget." Those need code:

function totalCost(it: Itinerary) {
  return it.days
    .flatMap((d) => d.activities)
    .reduce((sum, a) => sum + a.estimatedCostUsd, 0);
}

function checkBusinessRules(it: Itinerary, req: TripRequest): string[] {
  const problems: string[] = [];

  if (it.days.length !== req.days) {
    problems.push(`Expected ${req.days} days but got ${it.days.length}.`);
  }

  const total = totalCost(it);
  if (total > req.budgetUsd) {
    problems.push(`Activities cost $${total} in total, above the $${req.budgetUsd} budget.`);
  }

  it.days.forEach((d, i) => {
    if (d.day !== i + 1) problems.push(`Day numbering is wrong at position ${i + 1}.`);
    const times = d.activities.map((a) => a.time);
    if ([...times].sort().join() !== times.join()) {
      problems.push(`Day ${d.day} activities are not in chronological order.`);
    }
  });

  return problems;
}
Enter fullscreen mode Exit fullscreen mode

Step 5: The retry loop with feedback

This is the part that makes the system dependable. When validation fails, we don't just retry blindly. We tell the model what was wrong:

export async function planTrip(req: TripRequest, maxAttempts = 3) {
  let feedback: string[] | undefined;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const response = await client.messages.create({
      model: MODEL,
      max_tokens: 4000,
      system: SYSTEM,
      tools: [itineraryTool],
      tool_choice: { type: "tool", name: itineraryTool.name },
      messages: [{ role: "user", content: buildPrompt(req, feedback) }],
    });

    const call = response.content.find(
      (b): b is Anthropic.ToolUseBlock => b.type === "tool_use"
    );
    if (!call) {
      feedback = ["No structured itinerary was returned."];
      continue;
    }

    const parsed = ItinerarySchema.safeParse(call.input);
    if (!parsed.success) {
      feedback = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
      continue;
    }

    const problems = checkBusinessRules(parsed.data, req);
    if (problems.length === 0) {
      return { itinerary: parsed.data, totalCostUsd: totalCost(parsed.data), attempts: attempt };
    }
    feedback = problems;
  }

  throw new Error("Could not produce a valid itinerary");
}
Enter fullscreen mode Exit fullscreen mode

Two design choices worth noting:

  • Feedback is specific. "Day 2 activities are not in chronological order" fixes problems far more often than "try again."
  • Attempts are capped. An unbounded loop is an unbounded bill. Three is a sensible default, and you should log how often attempt two or three is needed. That number tells you whether your prompt or schema needs work.

Step 6: Wrap it in an API

// src/server.ts
import express from "express";
import { TripRequestSchema } from "./schema";
import { planTrip } from "./planner";

const app = express();
app.use(express.json({ limit: "10kb" }));

app.post("/api/plan", async (req, res) => {
  const input = TripRequestSchema.safeParse(req.body);
  if (!input.success) {
    return res.status(400).json({ error: input.error.flatten() });
  }

  try {
    const result = await planTrip(input.data);
    res.json(result);
  } catch (err) {
    console.error(err);
    res.status(502).json({ error: "Could not generate a valid itinerary. Please try again." });
  }
});

app.listen(3000, () => console.log("Trip planner on http://localhost:3000"));
Enter fullscreen mode Exit fullscreen mode

Run it and test:

npx tsx src/server.ts

curl -X POST http://localhost:3000/api/plan \
  -H "Content-Type: application/json" \
  -d '{"destination":"Lisbon","days":3,"budgetUsd":250,"interests":["food","history"]}'
Enter fullscreen mode Exit fullscreen mode

You'll get back typed JSON your frontend can map straight to UI: one card per day, a timeline per card, and a computed total.

Things that will bite you in production

The model can hallucinate places and prices. Structured output guarantees the shape, not the truth. Treat costs as estimates and label them that way. For real accuracy, ground the model in data: call a places API and pass verified results in, or verify each suggested venue afterwards.

Untrusted input goes into your prompt. Users control destination and interests. Keep length limits (as in the schema above), and never give the model tools that can take actions on the user's behalf without checks.

Rate limits and cost. Cache identical requests, set max_tokens deliberately, and add per-user rate limits before you expose this publicly.

Latency. Generating a long itinerary takes seconds. Consider streaming progress to the UI, or generating one day at a time so the first day appears quickly.

Test with an eval set. Keep 20 to 30 requests, including weird ones ("1 day in Antarctica on a $10 budget"), and run them after every prompt change. Track the validation-pass rate and the average number of attempts.

Where to go next

  • Add a regenerate day endpoint that changes one day while keeping the others fixed
  • Ground activities with a real places or maps API
  • Store itineraries in a database and let users edit them
  • Add streaming so the UI fills in as the plan is generated
  • Turn the schema into a shared package used by both backend and frontend

Wrapping up

The key idea is simple: treat the LLM as an untrusted data source, not as your app's logic. Constrain its output with a schema, validate it like any external input, enforce your own rules in code, and give it precise feedback when it gets things wrong. Do that, and "AI trip planner" stops being a demo and starts being a feature you can ship.

If you build on this, I'd love to hear what breaks first in your tests. Drop it in the comments.

Top comments (0)