In the previous chapter, Tool Construction, we built the chassis of our RemoteTriggerTool. We gave it a name and a description, effectively telling the AI, "Here is a car."
However, we haven't told the AI how to drive it yet. If we don't define exactly what inputs we accept, the AI might try to steer with a banana.
Code functions are strict. They expect precise data types (like a specific ID string or a boolean). AI models, on the other hand, are creative and fuzzy. They might say "Run that task please" instead of providing the exact ID needed to run it.
Schema Validation acts like a strict bouncer at a club.
action on the guest list? Is your trigger_id a string?"
This ensures that your internal logic (the call function) never crashes due to bad data.
To build our bouncer, we use a library called Zod. Zod allows us to create blueprints for data. If data doesn't match the blueprint, Zod throws an error before our code even runs.
We define two schemas:
Let's build the validation logic found in RemoteTriggerTool.ts.
We want the AI to be able to perform specific actions: list, get, create, update, or run.
We wrap our schema in a lazySchema helper. This essentially says, "Don't build this rulebook until someone actually asks to use the tool," which helps the application start faster.
// Importing Zod (the validator)
import { z } from 'zod/v4'
import { lazySchema } from '../../utils/lazySchema.js'
// Define the input shape
const inputSchema = lazySchema(() =>
z.strictObject({
// The strict bouncer: Only these specific words are allowed
action: z.enum(['list', 'get', 'create', 'update', 'run']),
// ... other fields go here
}),
)
Explanation: We create a strict object. The action field is an enum (enumeration), meaning it MUST be one of those five words. If the AI sends "delete", Zod blocks it immediately.
Some actions need extra info. If you want to run a trigger, you need an ID.
// Inside the z.strictObject...
// trigger_id is a string, but it is optional (not needed for 'list')
trigger_id: z
.string()
.regex(/^[\w-]+$/) // Must look like an ID (letters/numbers)
.optional()
.describe('Required for get, update, and run'),
Explanation: We mark trigger_id as .optional(). Why? Because if the action is just list, we don't need an ID. However, we add a .describe() text. This description is actually sent to the AI, helping it understand when to use this field.
For creating or updating tasks, the AI needs to send details (like the schedule time or script URL).
// Inside the z.strictObject...
// A record allows any keys with string names
body: z
.record(z.string(), z.unknown())
.optional()
.describe('JSON body for create and update'),
Explanation: z.record basically says "This is a JSON object with keys and values." We treat the values as unknown for now, letting the API decide if the specific fields are correct later.
After the tool runs, we need to return data to the AI. This schema ensures our code behaves predictably.
const outputSchema = lazySchema(() =>
z.object({
// The HTTP status code (e.g., 200 for OK, 404 for Not Found)
status: z.number(),
// The actual data, returned as a text string
json: z.string(),
}),
)
Explanation: We standardize the output. No matter what happened in the API, we return a status number and a json string. This consistency makes it easy for the AI to read the results.
Now that we have our schemas, we link them into the tool definition we started in Tool Construction.
export const RemoteTriggerTool = buildTool({
name: REMOTE_TRIGGER_TOOL_NAME,
// Link the Input Bouncer
get inputSchema(): InputSchema {
return inputSchema()
},
// Link the Output Structure
get outputSchema(): OutputSchema {
return outputSchema()
},
// ... rest of tool definition
})
Explanation: By using get, we ensure the schema is fetched fresh when requested. This connects our "Bouncer" to the "Chassis".
What happens when the AI tries to use the tool?
If the inputs are valid:
Because we defined these schemas, TypeScript knows exactly what our data looks like. This is a massive help when writing the code.
In RemoteTriggerTool.ts:
// TypeScript infers types directly from the Zod schema
type InputSchema = ReturnType<typeof inputSchema>
export type Input = z.infer<InputSchema>
async call(input: Input, context: ToolUseContext) {
// TypeScript knows 'input.action' exists!
const { action, trigger_id } = input
// It even knows 'action' can only be specific strings
if (action === 'create') {
// ...
}
}
Explanation: z.infer translates our validation rules into TypeScript types. If we try to type input.potato, our code editor will yell at us because potato isn't in the schema.
We have now installed the "Steering Wheel" (Input Schema) and the "Dashboard" (Output Schema).
action, trigger_id).status, json).Now that the data is validated and our tool runs, it produces a result. But how do we show that result to the human user in a nice way?
Generated by Code IQ