Skip to content

3. Open-Meteo tools

Checkpoint 3 of 69 minutes

Open-Meteo tools

Chain geocoding and forecast tools, validate model arguments, and recover from API errors.

defineTool creates a reusable, validated boundary between model intent and deterministic code. Descriptions teach the model when and how to call a tool; Valibot validates arguments before run; thrown errors become actionable tool errors; and the abort signal cancels in-flight fetches with the turn.

Create src/tools/weather.ts exactly as follows:

src/tools/weather.ts+94−0

New file from cp/2-hooks-state → cp/3-api-tools

src/tools/weather.ts
===================================================================
--- /dev/null cp/2-hooks-state
+++ b/src/tools/weather.ts cp/3-api-tools
@@ -0,0 +1,94 @@
+import { defineTool } from '@flue/runtime';
+import * as v from 'valibot';
+
+// Open-Meteo: free, no API key. https://open-meteo.com/en/docs
+
+export const geocodeCity = defineTool({
+ name: 'geocode_city',
+ description:
+ 'Look up a city by name and return its latitude, longitude, country and timezone. Call this before get_forecast.',
+ input: v.object({
+ city: v.pipe(v.string(), v.minLength(2), v.description('City name only, e.g. "Lisbon"')),
+ }),
+ async run({ data, signal }) {
+ // The geocoder matches names only, so "Lisbon, Portugal" -> "Lisbon".
+ const name = data.city.split(',')[0].trim();
+ const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(name)}&count=1&language=en&format=json`;
+ const res = await fetch(url, { signal });
+ if (!res.ok) throw new Error(`Geocoding failed: HTTP ${res.status}`);
+ const body = (await res.json()) as {
+ results?: Array<{ name: string; country: string; latitude: number; longitude: number; timezone: string }>;
+ };
+ const place = body.results?.[0];
+ // Throwing turns into a tool error the model can see and recover from.
+ if (!place) throw new Error(`No city found named "${name}". Ask the user to check the spelling.`);
+ return {
+ output: {
+ name: place.name,
+ country: place.country,
+ latitude: place.latitude,
+ longitude: place.longitude,
+ timezone: place.timezone,
+ },
+ };
+ },
+});
+
+// WMO weather codes -> short descriptions, so the model doesn't have to guess.
+const WEATHER: Record<number, string> = {
+ 0: 'clear sky', 1: 'mainly clear', 2: 'partly cloudy', 3: 'overcast', 45: 'fog', 48: 'rime fog',
+ 51: 'light drizzle', 53: 'drizzle', 55: 'dense drizzle', 61: 'light rain', 63: 'rain', 65: 'heavy rain',
+ 71: 'light snow', 73: 'snow', 75: 'heavy snow', 80: 'rain showers', 81: 'heavy showers',
+ 82: 'violent showers', 95: 'thunderstorm', 96: 'thunderstorm with hail', 99: 'severe thunderstorm with hail',
+};
+
+const isoDate = v.pipe(v.string(), v.isoDate());
+
+export const getForecast = defineTool({
+ name: 'get_forecast',
+ description:
+ 'Get the daily weather forecast (min/max °C, chance of rain, conditions) for a latitude/longitude between two dates (YYYY-MM-DD). Only works up to 16 days ahead. Get coordinates from geocode_city first.',
+ input: v.object({
+ latitude: v.number(),
+ longitude: v.number(),
+ startDate: isoDate,
+ endDate: isoDate,
+ }),
+ async run({ data, signal }) {
+ const params = new URLSearchParams({
+ latitude: String(data.latitude),
+ longitude: String(data.longitude),
+ daily: 'temperature_2m_max,temperature_2m_min,precipitation_probability_max,weather_code',
+ start_date: data.startDate,
+ end_date: data.endDate,
+ timezone: 'auto',
+ });
+ const res = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`, { signal });
+ if (!res.ok) {
+ const reason = ((await res.json().catch(() => ({}))) as { reason?: string }).reason;
+ const today = new Date().toISOString().slice(0, 10);
+ throw new Error(
+ `Forecast unavailable for ${data.startDate}..${data.endDate}: ${reason ?? `HTTP ${res.status}`}. ` +
+ `Forecasts only cover today (${today}) up to 16 days ahead.`,
+ );
+ }
+ const { daily } = (await res.json()) as {
+ daily: {
+ time: string[];
+ temperature_2m_min: number[];
+ temperature_2m_max: number[];
+ precipitation_probability_max: number[];
+ weather_code: number[];
+ };
+ };
+ return {
+ output: daily.time.map((date, i) => ({
+ date,
+ minC: daily.temperature_2m_min[i],
+ maxC: daily.temperature_2m_max[i],
+ rainChancePct: daily.precipitation_probability_max[i],
+ conditions: WEATHER[daily.weather_code[i]] ?? `code ${daily.weather_code[i]}`,
+ })),
+ };
+ },
+});

Update src/agents/field-trip.ts: import and register both weather tools, then add the current date and the geocoding → forecast rule to the instructions:

src/agents/field-trip.ts+10−1

Changes from cp/2-hooks-state → cp/3-api-tools

src/agents/field-trip.ts
===================================================================
--- a/src/agents/field-trip.ts cp/2-hooks-state
+++ b/src/agents/field-trip.ts cp/3-api-tools
@@ -2,6 +2,7 @@
import { useModel, usePersistentState, useTool } from '@flue/runtime';
import * as v from 'valibot';
+import { geocodeCity, getForecast } from '../tools/weather.ts';
// The trip brief the agent remembers for this conversation.
type TripBrief = {
@@ -42,16 +43,24 @@
},
});
+ // Tools that call an external API (Open-Meteo), defined in src/tools/weather.ts.
+ useTool(geocodeCity);
+ useTool(getForecast);
+
// The agent re-renders before every model call, so these instructions
// always reflect the latest saved brief.
const hasBrief = Object.keys(brief).length > 0;
+ const today = new Date().toISOString().slice(0, 10);
return `You are FieldTrip, a helpful team-offsite planner. You help groups plan memorable offsites by understanding their destination, dates, headcount, budget, and interests.
Rules:
1. If the user's message contains ANY trip detail (city, dates, headcount, budget, interests), your FIRST action is to call \`save_trip_brief\` with those fields. Do this before writing any reply.
2. Answer questions about the trip from the saved brief below. If a detail is missing, ask for it.
-3. Keep replies short: at most 120 words unless the user asks for more detail.
+3. For weather questions: call \`geocode_city\` for the city, then \`get_forecast\` with its latitude/longitude and the trip dates (use the saved brief). If there is no end date, use the start date. Summarise the forecast per day in plain words; if a tool returns an error, explain it to the user.
+4. Keep replies short: at most 120 words unless the user asks for more detail.
+Today is ${today}.
+
## Saved trip brief
${hasBrief ? JSON.stringify(brief, null, 2) : '(nothing saved yet)'}`;
}

Trip dates are filled automatically in the commands and their copy buttons. The guide reuses your workshop dates.

Terminal window
npm run deploy
sleep 5
npm run smoke -- https://field-trip-agent.<subdomain>.workers.dev cp3-live "Offsite in Lisbon from <START> to <END> for 14 people, budget 400 EUR each. We like food and hiking."
npm run smoke -- https://field-trip-agent.<subdomain>.workers.dev cp3-live "What's the weather forecast for our trip dates?"

The second turn should call geocode_city, then get_forecast with the returned coordinates and dates from persistent state.

If both tool calls and ✔ completed appear without a prose summary, ask the model to finish in the same conversation:

Terminal window
npm run smoke -- https://field-trip-agent.<subdomain>.workers.dev cp3-live "Please summarize that forecast for each day in plain language."

Optional depth check: exercise the error path in a fresh conversation:

Terminal window
npm run smoke -- https://field-trip-agent.<subdomain>.workers.dev cp3-live-err "What will the weather be in Lisbon on 2099-03-01?"

The forecast tool should fail clearly and the agent should explain the 16-day limit instead of inventing weather.

Save your workers.dev subdomain to open this test ↗https://field-trip-agent.<subdomain>.workers.dev/?id=cp3-live

This opens the same conversation as the smoke test. If you repeat this checkpoint, before copying its commands.

Verification gate

Prove it works

  • geocode_city runs before get_forecast.
  • The forecast uses coordinates from geocoding and dates from saved state.
  • The reply summarizes each supported day in plain language.
  • Optional: an out-of-range date produces a tool error and no fabricated forecast.
Optional mini quizCheck tool boundaries3 questions instant feedback
1What validates model-generated tool arguments?
2Why pass signal to fetch?
3How should an external API failure be represented?
Recovery lane

Get back on track in under a minute

If a dev server is running, stop it first. Uncommitted work is stashed and the current commit gets a backup branch before anything moves.

Option 01

Restart this exercise

Return to cp/2-hooks-state, rebuild this exercise, then use its Live deploy verification tab. Stop any running dev server with Ctrl+C before switching.

Terminal
git stash push --include-untracked -m "workshop recovery"
git fetch origin
git branch -f workshop-backup HEAD
git switch --no-track -C workshop origin/cp/2-hooks-state
npm ci
Option 02

Catch up with the room

Jump to cp/3-api-tools, then use this checkpoint's Live deploy verification tab. Stop any running dev server with Ctrl+C before switching.

Terminal
git stash push --include-untracked -m "workshop recovery"
git fetch origin
git branch -f workshop-backup HEAD
git switch --no-track -C workshop origin/cp/3-api-tools
npm ci

Next: Delegate venue research →