Skip to content
bits
Give your model tools · Lesson 2 of 9 · 25 min

Your first tool: the weather

Desk: best at a computer, with a terminal

Verified ai 7.0.162zod 4.6.7agents 0.33.0wrangler 4.157.0

Start from b-01 on GitHub. Starting here, or behind? Get the code at that tag:

Shell
git clone https://github.com/ryanyogan/klippy
cd klippy
git checkout b-01
Tutor:

Klippy can talk, but it can't look anything up. Ask it about the weather and the best it can do is guess. In this lesson you'll write its first tool, getWeather, which fetches live weather from a free service, and let Claude decide when to call it.

Tutor:

1. One new package

Claude needs to know what arguments a tool takes. You'll describe them with zod, a library for describing the shape of data in TypeScript. The AI SDK turns a zod description into JSON Schema, the format Claude reads.

Shell
pnpm --filter agent add --save-exact zod@4.6.7
Tutor:

2. Describe the tool

A tool has three parts: a description in plain English, an input schema for its arguments, and an execute function that does the work. Claude only ever sees the first two. Make apps/agent/src/tools/weather.ts, starting with the description and the schema:

import { tool } from 'ai';
import { z } from 'zod';
// Open-Meteo is free and needs no key (non-commercial use, under 10,000 calls a day).
export const getWeather = tool({
description: 'Get the current weather for a place.',
inputSchema: z.object({
place: z.string().describe('A city or town, e.g. "Berlin"'),
}),
TypeScriptUTF-8at b-02● lines 5–9: apps/agent/src/tools/weather.ts
Tutor:
  • tool({...}) comes from the AI SDK and bundles the three parts.

  • The description is short on purpose for now. In lesson 4 you'll see why a better one changes what Claude does.

  • z.object({ place: z.string() }) says the input is an object with one string, place. .describe(...) is a note for Claude about that field, with an example of the format you want.

quick check+20 neurons
Tutor:

Claude has to decide whether a question needs getWeather. What does it go on?

    Pick one option

    Pick one, then Check.

    Tutor:

    3. Do the work

    Now the part that runs when Claude asks. Add execute inside the tool({...}), after inputSchema:

    execute: async ({ place }) => {
    // 1. Turn the place name into coordinates.
    const geo = await getJson(`https://geocoding-api.open-meteo.com/v1/search?count=1&name=${encodeURIComponent(place)}`);
    const hit = geo.results?.[0];
    if (!hit) return { error: `I couldn't find a place called ${place}.` };
    // 2. Ask for the current weather there.
    const { current } = await getJson(
    `https://api.open-meteo.com/v1/forecast?latitude=${hit.latitude}&longitude=${hit.longitude}&current=temperature_2m,weather_code,wind_speed_10m`,
    );
    return {
    place: `${hit.name}, ${hit.country}`,
    temperatureC: current.temperature_2m,
    windKmh: current.wind_speed_10m,
    conditions: describeWeather(current.weather_code),
    };
    },
    });
    TypeScriptUTF-8at b-02● lines 2–16: apps/agent/src/tools/weather.ts
    Tutor:
    • Open-Meteo is free and needs no key. It works in two steps: turn a place name into coordinates (geocoding), then ask for the weather there.

    • A place it can't find returns { error: ... } as a normal result. Claude reads it and tells the user, instead of the request failing.

    • The result is a small object with just what's needed: Claude reads every field you return, and every field costs tokens.

    Two helpers finish the file. Add them at the bottom:

    async function getJson(url: string) {
    const response = await fetch(url);
    return response.json<any>();
    }
    // Open-Meteo sends a WMO weather code. These are the main groups.
    function describeWeather(code: number) {
    if (code === 0) return 'clear sky';
    if (code <= 3) return 'partly cloudy';
    if (code <= 48) return 'fog';
    if (code <= 57) return 'drizzle';
    if (code <= 67) return 'rain';
    if (code <= 77) return 'snow';
    if (code <= 82) return 'rain showers';
    if (code <= 86) return 'snow showers';
    return 'thunderstorm';
    }
    TypeScriptUTF-8at b-02
    Tutor:

    getJson fetches a URL and reads the JSON. describeWeather turns Open-Meteo's numeric weather code into words. Lesson 7 makes getJson honest about failures.

    Tutor:

    4. Hand Klippy the tool

    In apps/agent/src/ask.ts, import the tool and give it to generateText:

    import { generateText, isStepCount } from 'ai';
    import { model } from './model';
    import { getWeather } from './tools/weather';
    // ...
    const { text, providerMetadata } = await generateText({
    model: model(env),
    system: 'You are Klippy, a friendly personal assistant. Answer in one or two sentences.',
    messages: [{ role: 'user', content: question }],
    maxOutputTokens: 300, // a hard cap on the reply, so one request can't run up a bill
    tools: { getWeather },
    // Step 1: Claude may call a tool. Step 2: it answers with the result. Then stop.
    stopWhen: isStepCount(2),
    });
    TypeScriptUTF-8at b-02● lines 10–12: apps/agent/src/ask.ts
    Tutor:
    • tools: { getWeather } sends the tool's description and schema to the model with every request. The key, getWeather, is the name Claude uses.

    • stopWhen: isStepCount(2) is the important new line. With a tool, one question can take two model calls, called steps: in step 1, Claude asks for the tool and the SDK runs it; in step 2, Claude answers with the result. Then it stops, whatever happens, so a confused model can't loop and run up your bill. (Older code calls it stepCountIs; in ai 7 that's an alias of isStepCount.)

    Tutor:

    5. Ask about the weather

    With pnpm dev running, ask with your token (from Your first Worker, in apps/agent/.dev.vars):

    Shell
    curl -s -X POST http://localhost:8787/ask -H "Authorization: Bearer $KLIPPY_TOKEN" -d '{"question":"What is the weather in Berlin right now?"}'

    Run export KLIPPY_TOKEN=... with your token first, or paste it in place of $KLIPPY_TOKEN. Here's ours:

    recorded output
    curl -s -X POST http://localhost:8801/ask -H "Authorization: Bearer demo-token-for-the-recording" -d '{"question":"What is the weather in Berlin right now?"}'
    {"answer":"It's currently 5.6°C in Berlin with partly cloudy skies and light winds of 7.1 km/h.","model":"@cf/zai-org/glm-4.7-flash"}
    recorded output
    curl -s -X POST http://localhost:8801/ask -H "Authorization: Bearer demo-token-for-the-recording" -d '{"question":"Is it raining in Llanfairpwllgwyngyll?"}'
    {"answer":"No, it's not raining in Llanfairpwllgwyngyll; the weather is partly cloudy with a temperature of 8.7°C.","model":"@cf/zai-org/glm-4.7-flash"}
    Tutor:

    Those numbers are real: they came from Open-Meteo on 3 November 2026. The answers were written by the free fallback model (see model), since our gateway has no Anthropic key. With yours, Claude writes them.

    If the answer guesses instead of giving numbers, check that tools: { getWeather } is in ask.ts and that the dev server reloaded. If it's empty, the model ran out of steps: check isStepCount(2).

    Tutor:

    6. Tests that don't call anyone

    Klippy's tests fake two things: the model, which "decides" to call getWeather and then answers, and Open-Meteo, so no request leaves your machine. They check that the tool's result is what goes back to the model. Copy test/weather.spec.ts and the updated test/fake-model.ts from the repo, then run pnpm test. Ours: 16 passed.

    One habit for the whole course: each lesson's tests live in the repo at that lesson's tag, and earlier test files sometimes grow too. The simplest way to stay in step is to copy the whole apps/agent/test/ folder from the tag at the end of each lesson.

    Commit. Your code matches tag b-02 (changes from b-01).