Odel

Errors

Typed error codes and the ModuleError factories that produce them.

When a tool fails, the caller is usually a language model deciding what to do next. A thrown TypeError tells it nothing; ModuleError.missingSecret('RESEND_API_KEY') tells it the user needs to configure something, which is a different situation with a different fix.

ModuleError is the SDK's structured error. Throw it from a handler and it serializes into a shape callers can branch on.

import { ErrorCode, ModuleError } from '@odel/module-sdk';

Codes

Codes are grouped by cause, and the range is the group:

RangeCauseCodes
1xxxValidationINVALID_INPUT 1001, MISSING_REQUIRED_FIELD 1002, INVALID_FORMAT 1003
2xxxCredentialsMISSING_SECRET 2001, INVALID_SECRET 2002, UNAUTHORIZED 2003
3xxxSomething upstreamAPI_ERROR 3001, NETWORK_ERROR 3002, TIMEOUT 3003, NOT_FOUND 3004
4xxxLimitsRATE_LIMIT_EXCEEDED 4001, QUOTA_EXCEEDED 4002
5xxxYour moduleINTERNAL_ERROR 5001, NOT_IMPLEMENTED 5002, CONFIGURATION_ERROR 5003

The grouping matters more than the individual numbers. A caller that only checks Math.floor(code / 1000) can still tell "the user must fix their input" from "wait and retry" without knowing every code you might throw.

Factories

Each factory fills in the code and writes the message, so the same failure reads the same way across every module:

FactoryCodeMessage
validationError(message, metadata?)INVALID_INPUTyours
missingField(fieldName)MISSING_REQUIRED_FIELDRequired field "x" is missing
missingSecret(secretName)MISSING_SECRETRequired secret "X" is not configured
invalidSecret(secretName, reason?)INVALID_SECRETSecret "X" is invalid: reason
apiError(message, metadata?)API_ERRORyours
networkError(message, metadata?)NETWORK_ERRORyours
timeout(operation, timeoutMs?)TIMEOUTOperation "x" timed out
notFound(resource, identifier?)NOT_FOUNDResource "id" not found
rateLimitError(retryAfter?)RATE_LIMIT_EXCEEDEDRate limit exceeded
quotaExceeded(quotaType?)QUOTA_EXCEEDEDx quota exceeded
internalError(message, metadata?)INTERNAL_ERRORyours
notImplemented(feature)NOT_IMPLEMENTEDFeature "x" is not implemented
configurationError(message, metadata?)CONFIGURATION_ERRORyours
throw ModuleError.missingSecret('RESEND_API_KEY');
throw ModuleError.invalidSecret('API_KEY', 'must start with sk-');
throw ModuleError.apiError('Service unavailable', { statusCode: 503 });
throw ModuleError.timeout('weather lookup', 30_000);
throw ModuleError.rateLimitError(60); // seconds until a retry is worth trying

INVALID_FORMAT and UNAUTHORIZED have no factory. Use the constructor:

throw new ModuleError(ErrorCode.UNAUTHORIZED, 'Admin access required', { role: 'member' });

Metadata

Every factory that takes metadata puts it in the serialized error untouched. It is for machine detail that doesn't belong in a sentence — a status code, the field that failed, how long to wait:

ModuleError.apiError('Service unavailable', { statusCode: 503 }).toJSON();
// {
//   success: false,
//   error: 'Service unavailable',
//   code: 3001,
//   metadata: { statusCode: 503 }
// }

metadata is omitted entirely when you don't pass any, rather than serialized as an empty object.

Metadata travels back to the caller. Don't put a secret, a raw upstream response, or a full stack trace in it — attach the status code, not the response body that contained the API key you sent.

In a handler

server.registerTool(
	'send-email',
	{
		description: 'Send an email',
		inputSchema: { to: validators.email(), subject: validators.nonEmptyString() }
	},
	async (args, extra) => {
		// Throws ModuleError.missingSecret if the user hasn't configured it
		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)
		});

		if (!response.ok) {
			throw ModuleError.apiError('Failed to send email', { statusCode: response.status });
		}

		const result = { success: true as const, messageId: (await response.json()).id };
		return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
	}
);

Throwing is one of two options. A failure the caller should treat as a result — "no matches found", "this address bounced" — is often better returned as a { success: false, error } value, which is what SuccessResponseSchema describes. Throw when the call could not be completed; return when it completed and the answer was no.

On this page