Tools go wrong in two ways. The model sends bad input, like a time zone that doesn't exist. Or the input is fine and the call fails: the weather service is down. In both cases the worst thing Klippy can do is crash, or make something up. The right thing is to tell the model what went wrong, so it can tell the user. In this lesson you'll make both failures honest.
Verified ai 7.0.162zod 4.6.7agents 0.33.0
Start from b-06 on GitHub. Starting here, or behind? Get the code at that tag:
git clone https://github.com/ryanyogan/klippy cd klippy git checkout b-06
1. Input the schema can't describe
getTime takes a timeZone string. Ask for the time in "Mars/Olympus_Mons" and that's a string, so the schema lets it through, and toLocaleString throws. A schema can say "a string"; it can't say "a real time zone". So add a check that runs in your code. In apps/agent/src/tools/time.ts, replace the schema:
and add the check at the bottom of the file:
.refine(isTimeZone, message)runsisTimeZoneon the value after the model calls. If it returnsfalse, the input is rejected andexecutenever runs.Intl.DateTimeFormatthrows for a zone it doesn't know, which makes it a handy test.The message is written for the model: what was wrong and what to send instead ("Use a name like Europe/Berlin"). A good error is a hint.
The AI SDK doesn't stop when input is rejected. It sends the error back to the model as the tool's result (Claude's API marks it is_error), and the model gets its next step to fix the call or explain.
2. A call that fails
Right now, if Open-Meteo answers with an error page, getJson tries to read it as JSON and Klippy fails in a confusing way. Check the status first. In apps/agent/src/tools/weather.ts:
response.ok is false for any 4xx or 5xx status. Throwing here is right: the AI SDK catches it, and the model is told the tool failed, with your message. It can then say "the weather service isn't answering" instead of inventing a forecast.
3. See the errors in your log
Add one field to the step log in apps/agent/src/ask.ts, after toolResults:
A failed call doesn't appear in toolResults; it's a tool-error part of the step. This pulls them out, one per failed tool.
Now ask for the time on Mars. Our run:
Step 0: the model sent the bad zone, your refinement refused it, and the error went back. Step 1: the model read the error and told the truth. (The free fallback model again; Claude does the same with the same error.)
Klippy's test/failures.spec.ts checks both: a bad zone never reaches execute, and a 502 from the weather service comes back to the model as an error. Ours: 27 passed.
Write an error Claude can use
The sandbox can't run your tools, but it can show what a good error does. Write the error your weather tool would return for a misspelled city, and ask Claude what it does next.
Goal Write the error your weather tool returns for a misspelled city, so well that Claude's next step is obvious and right
What good looks likeThe coach checks the reply
- Says it's a tool errornot checked yet
- Asks what Claude would donot checked yet
- Ran at 1.0not checked yet
- Answers in a sentence or twonot checked yet
- The error says what went wrong and what to try insteadnot checked yet
- Claude's next step follows from your error's suggestion, not from a guessnot checked yet
- Claude doesn't make up the weathernice to have
Run your prompt and the replies land here.
What about strict?
Claude also offers strict tool use: mark a tool strict: true and Claude's input is guaranteed to match your schema: the right types, every required field, only allowed enum values. The AI SDK has a strict option per tool for it.
Two honest notes. First, strict guards the shape, not the meaning: a strict timeZone is still a string, and "Mars/Olympus_Mons" still fits. Strict schemas also can't use limits like a maximum length, so checks like .max(1000) on a note and .refine on a zone stay in your code either way. Second, Klippy doesn't set strict, because the version of the Agents SDK it uses (0.33.0) doesn't pass that flag on to the model. A flag that does nothing would mislead you. Your code's own checks are what Klippy relies on, and they work with any model.
Commit. Your code matches tag b-07 (changes from b-06).