Response schemas
Declare what a tool returns, so callers get types instead of guesses.
A tool without an output schema returns text, and every caller has to guess at its shape. An
outputSchema turns that guess into a contract — published in tools/list alongside the input
schema, and used by anything that generates code against your module.
Why it pays off
In Odel's chat, a language model doesn't call your tool through a JSON blob — it writes TypeScript
against a generated declaration. With an outputSchema, the model sees the real shape:
interface GetWeatherOutput {
temperature: { celsius: number; fahrenheit: number };
conditions: 'sunny' | 'cloudy' | 'rainy' | 'stormy' | 'snowy';
humidity: number;
}
declare const modules: {
'you/weather': {
/** Get weather for a city */
'get-weather': (input: GetWeatherInput) => Promise<GetWeatherOutput>;
};
};Without one, that return type falls back to:
Promise<{ success: true; [key: string]: any } | { success: false; error: string }>Which is to say the model has to write defensive code around a value it can't see into, and can't chain your tool into another one with any confidence.
Declaring it
import { z } from 'zod';
server.registerTool(
'get-weather',
{
description: 'Get weather for a city',
inputSchema: { city: z.string(), country: z.string() },
outputSchema: {
temperature: z.object({ celsius: z.number(), fahrenheit: z.number() }),
conditions: z.enum(['sunny', 'cloudy', 'rainy', 'stormy', 'snowy']),
humidity: z.number().min(0).max(100)
}
},
async ({ city, country }) => {
const weather = await fetchWeather(city, country);
const structuredContent = {
temperature: { celsius: weather.temp, fahrenheit: (weather.temp * 9) / 5 + 32 },
conditions: weather.conditions,
humidity: weather.humidity
};
return {
content: [{ type: 'text', text: JSON.stringify(structuredContent) }],
structuredContent
};
}
);Return both. structuredContent is the typed value and must match the schema you declared;
content is the human-readable rendering, and JSON-stringifying the same object is the usual way
to produce it. Clients that understand structured output read the first; everything else reads the
second.
Helper schemas
Most tools want the same success-or-failure envelope, which is also the shape the platform assumes
when you declare nothing. SuccessResponseSchema builds it from your data fields:
import { SuccessResponseSchema } from '@odel/module-sdk';
const EmailResponse = SuccessResponseSchema(
z.object({ messageId: z.string(), sentAt: z.string() })
);
// { success: true; messageId: string; sentAt: string }
// | { success: false; error: string }SimpleSuccessSchema is the same idea with no data — { success: true } or
{ success: false, error } — for tools that only report whether the thing happened.
Two type helpers pull either arm back out, which saves re-describing the shape in your own function signatures:
import type { SuccessResponse, ErrorResponse } from '@odel/module-sdk';
type Sent = SuccessResponse<typeof EmailResponse>; // { success: true; messageId: string; sentAt: string }
type Failed = ErrorResponse<typeof EmailResponse>; // { success: false; error: string }Use `as const` on the literal
Write success: true as const, not success: true. Without it TypeScript widens the field to
boolean, the value stops matching either arm of the union, and you get an error pointing at the
return statement rather than at the missing annotation.
Failing inside the envelope
With this schema, an expected failure is a return value rather than a throw:
async (args, extra) => {
const apiKey = getRequiredSecret(extra, 'RESEND_API_KEY');
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: { authorization: `Bearer ${apiKey}` },
body: JSON.stringify(args)
});
const result = response.ok
? { success: true as const, messageId: (await response.json()).id }
: { success: false as const, error: `Resend returned ${response.status}` };
return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
}Both arms satisfy the schema, so the caller gets a value it can branch on either way.
Throwing a ModuleError is the other option, and the line
between them is the same one as always: return when the call completed and the answer was no,
throw when the call could not be completed at all.