- Create new Next.js app at apps/playground for testing TPMJS tools - Implement AI SDK v6 patterns with DefaultChatTransport and UIMessage format - Create template tool package at packages/tools/hello with hello-world and hello-name tools - Use tool() and jsonSchema() helpers to avoid Zod 4 conversion issues with OpenAI - Add static tool loading system with switch statement (Next.js/webpack compatible) - Implement chat interface with tool call visualization showing inputs/outputs - Support multi-step tool execution with stepCountIs(5) - Stream responses with toUIMessageStreamResponse() for full tool support - Add sidebar showing available tools (static list) - Use parts-based message rendering for text and tool calls - Integrate firecrawl-aisdk tools (scrape, crawl, search) - Add theme toggle in header (defaults to light mode) - Fix responsive layout with max-width for message bubbles - Use biome-ignore comments for legitimate any types in tool loading 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
14 KiB
OpenAI Schema Validation Error - AI SDK v6
✅ RESOLVED
Solution: Use tool() and jsonSchema() from AI SDK instead of Zod for tool definitions.
Error Message
Error [AI_APICallError]: Invalid schema for function 'helloWorld': schema must be a JSON Schema of 'type: "object"', got 'type: "None"'.
Context
Building a Next.js playground app to test AI SDK v6 tool execution with OpenAI's GPT-4o-mini model. The error occurs when OpenAI validates the tool schema sent in the API request.
Root Cause
Zod 4.0.0 generates JSON Schema with allOf + $ref at the root level instead of a direct type: "object". OpenAI's API requires a JSON Schema with type: "object" at the root, so it rejects Zod 4 schemas with type: "None" error.
Environment
- AI SDK Version:
ai@6.0.0-beta.124 - OpenAI Provider:
@ai-sdk/openai@3.0.0-beta.74 - OpenAI Library:
openai@^6.9.1 - Zod Version:
zod@^4.0.0 - Next.js Version:
next@^16.0.4 - Node.js: Latest
- TypeScript: Strict mode enabled
Tool Definition
Located at: packages/tools/hello/src/index.ts
import { z } from 'zod';
/**
* Hello World Tool
* Returns a simple "Hello, World!" greeting
*
* This is a proper AI SDK v6 tool that can be used with streamText()
*/
export const helloWorldTool = {
description: 'Returns a simple "Hello, World!" greeting message',
parameters: z.object({
// OpenAI requires at least one optional parameter, can't be completely empty
includeTimestamp: z.boolean().optional().describe('Whether to include a timestamp in the response'),
}),
execute: async ({ includeTimestamp = true }: { includeTimestamp?: boolean }) => {
const response: any = {
message: 'Hello, World!',
};
if (includeTimestamp) {
response.timestamp = new Date().toISOString();
}
return response;
},
};
/**
* Hello Name Tool
* Returns a personalized greeting with the provided name
*
* This is a proper AI SDK v6 tool that can be used with streamText()
*/
export const helloNameTool = {
description: 'Returns a personalized greeting with the provided name',
parameters: z.object({
name: z.string().describe('The name of the person to greet'),
}),
execute: async ({ name }: { name: string }) => {
return {
message: `Hello, ${name}!`,
timestamp: new Date().toISOString(),
};
},
};
Tool Loading
Located at: apps/playground/src/lib/tool-loader.ts
// Static imports for tools (required for Next.js/webpack)
import { helloWorldTool, helloNameTool } from '@tpmjs/hello';
import { scrapeTool, crawlTool, searchTool } from 'firecrawl-aisdk';
/**
* Load a specific TPMJS tool by package name
*/
export async function loadTpmjsTool(packageName: string): Promise<any> {
try {
// Map package names to their tool functions
switch (packageName) {
case '@tpmjs/hello':
// Hello has multiple tools, return all of them
return {
helloWorld: helloWorldTool,
helloName: helloNameTool,
};
case 'firecrawl-aisdk':
// Firecrawl has multiple tools, return all of them
return {
scrapeTool,
crawlTool,
searchTool,
};
default:
throw new Error(`Unknown tool package: ${packageName}`);
}
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to load tool from package ${packageName}: ${error.message}`);
}
throw new Error(`Failed to load tool from package ${packageName}: Unknown error`);
}
}
/**
* Load all installed TPMJS tools
*/
export async function loadAllTools(): Promise<Record<string, any>> {
const installedTools = ['@tpmjs/hello', 'firecrawl-aisdk'];
const tools: Record<string, any> = {};
for (const packageName of installedTools) {
try {
const tool = await loadTpmjsTool(packageName);
// If the tool returns an object with multiple tools (like firecrawl), spread them
if (tool && typeof tool === 'object' && !tool.description) {
Object.assign(tools, tool);
} else {
// Single tool - use a cleaned name (remove hyphens, camelCase)
const toolName = packageName.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase()).replace(/-/g, '');
tools[toolName] = tool;
}
} catch (error) {
console.error(`Failed to load tool ${packageName}:`, error);
// Continue loading other tools even if one fails
}
}
return tools;
}
API Route
Located at: apps/playground/src/app/api/chat/route.ts
import { loadAllTools } from '~/lib/tool-loader';
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';
import { NextRequest } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 60;
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { messages } = body;
if (!messages || !Array.isArray(messages)) {
return new Response(JSON.stringify({ error: 'Invalid request: messages array required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// Load all available tools
const tools = await loadAllTools();
console.log('Loaded tools:', Object.keys(tools));
// Create system message
const systemMessage = {
role: 'system' as const,
content: `You are a helpful AI assistant that can use TPMJS tools to help users.
Available tools:
${Object.entries(tools)
.map(([name, tool]) => `- ${name}: ${tool.description}`)
.join('\n')}
Call tools as needed to answer user questions. Execute tools directly.`,
};
// Stream the AI response with tools
const result = streamText({
model: openai('gpt-4o-mini'),
messages: [systemMessage, ...messages],
tools,
maxSteps: 5,
});
return result.toTextStreamResponse();
} catch (error) {
console.error('Chat API error:', error);
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : 'Unknown error occurred',
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
);
}
}
Package Configuration
Located at: packages/tools/hello/package.json
{
"name": "@tpmjs/hello",
"version": "0.0.1",
"private": true,
"description": "Example TPMJS tools - Hello World and Hello Name",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"type-check": "tsc --noEmit"
},
"keywords": [
"tpmjs-tool",
"ai-sdk",
"hello",
"example"
],
"tpmjs": {
"category": "text-analysis",
"description": "Simple greeting tools - Hello World and personalized Hello Name greetings"
},
"dependencies": {
"ai": "6.0.0-beta.124",
"zod": "^4.0.0"
},
"devDependencies": {
"@tpmjs/tsconfig": "workspace:*",
"typescript": "^5.9.3"
},
"files": [
"dist",
"README.md"
]
}
TypeScript Configuration
Located at: packages/tools/hello/tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Compiled Output
Located at: packages/tools/hello/dist/index.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.helloNameTool = exports.helloWorldTool = void 0;
const zod_1 = require("zod");
/**
* Hello World Tool
* Returns a simple "Hello, World!" greeting
*
* This is a proper AI SDK v6 tool that can be used with streamText()
*/
exports.helloWorldTool = {
description: 'Returns a simple "Hello, World!" greeting message',
parameters: zod_1.z.object({
// OpenAI requires at least one optional parameter, can't be completely empty
includeTimestamp: zod_1.z.boolean().optional().describe('Whether to include a timestamp in the response'),
}),
execute: async ({ includeTimestamp = true }) => {
const response = {
message: 'Hello, World!',
};
if (includeTimestamp) {
response.timestamp = new Date().toISOString();
}
return response;
},
};
/**
* Hello Name Tool
* Returns a personalized greeting with the provided name
*
* This is a proper AI SDK v6 tool that can be used with streamTime()
*/
exports.helloNameTool = {
description: 'Returns a personalized greeting with the provided name',
parameters: zod_1.z.object({
name: zod_1.z.string().describe('The name of the person to greet'),
}),
execute: async ({ name }) => {
return {
message: `Hello, ${name}!`,
timestamp: new Date().toISOString(),
};
},
};
Full Error Response from OpenAI
{
"error": {
"message": "Invalid schema for function 'helloWorld': schema must be a JSON Schema of 'type: \"object\"', got 'type: \"None\"'.",
"type": "invalid_request_error",
"param": "tools[0].parameters",
"code": "invalid_function_parameters"
}
}
API endpoint: https://api.openai.com/v1/responses
Status code: 400
Problem Analysis
-
OpenAI expects JSON Schema format - The
tools[0].parametersfield must be a valid JSON Schema object withtype: "object" -
AI SDK v6 should convert Zod to JSON Schema - The AI SDK is supposed to automatically convert Zod schemas to JSON Schema when sending to OpenAI, but it's producing
type: "None"instead -
Potential causes:
- Zod 4.0.0 compatibility issue with AI SDK v6 beta
- AI SDK not properly converting the Zod schema
- Issue with how the tool object is structured
- Problem with how tools are passed to
streamText()
-
Already tried:
- Added at least one parameter (even optional) to helloWorldTool
- Used proper Zod schema with
.describe()for descriptions - Followed AI SDK v6 tool definition format exactly
- Built the package successfully (dist folder exists)
AI SDK v6 Tool Format Reference
According to AI SDK v6 documentation, a tool should be defined as:
{
description: string,
parameters: ZodSchema,
execute: async (args) => Promise<any>
}
This matches our implementation exactly.
Questions for ChatGPT
-
Is there a known compatibility issue between AI SDK v6 Beta (6.0.0-beta.124) and Zod 4.0.0?
-
Does the AI SDK v6 require a specific tool registration format when passing to
streamText()? -
Should tools be wrapped in a different structure (e.g., using
tool()helper function)? -
Is there a way to manually convert Zod schema to JSON Schema that OpenAI accepts?
-
Are there any known issues with using workspace packages (
@tpmjs/hello) in Next.js API routes with dynamic imports? -
Should we downgrade to Zod 3.x instead of Zod 4.0.0?
-
Is there a debug mode to see what JSON Schema is being sent to OpenAI?
Additional Context
- The
firecrawl-aisdkpackage works correctly with the same setup - Build process completes successfully with no TypeScript errors
- The tool is being loaded and passed to
streamText()correctly - Error only occurs when OpenAI validates the tool schema
- This is a monorepo using pnpm workspaces and Turborepo
Related Files
- Tool definition:
packages/tools/hello/src/index.ts - Tool loader:
apps/playground/src/lib/tool-loader.ts - API route:
apps/playground/src/app/api/chat/route.ts - Package config:
packages/tools/hello/package.json - Compiled output:
packages/tools/hello/dist/index.js
Expected Behavior
Tools should be automatically converted from Zod schema to JSON Schema by AI SDK v6 and accepted by OpenAI's API.
Actual Behavior
OpenAI rejects the tool schema with error: got 'type: "None"' instead of a valid JSON Schema object.
✅ SOLUTION IMPLEMENTED
What We Changed
Instead of using Zod schemas with parameters, we now use AI SDK's tool() helper with jsonSchema() for the input schema. This bypasses Zod's JSON Schema conversion entirely.
Before (Broken with Zod 4)
import { z } from 'zod';
export const helloWorldTool = {
description: 'Returns a simple "Hello, World!" greeting message',
parameters: z.object({
includeTimestamp: z.boolean().optional().describe('Whether to include a timestamp'),
}),
execute: async ({ includeTimestamp = true }) => {
// ...
},
};
After (Working with jsonSchema)
import { jsonSchema, tool } from 'ai';
type HelloWorldInput = {
includeTimestamp?: boolean;
};
export const helloWorldTool = tool({
description: 'Returns a simple "Hello, World!" greeting message',
inputSchema: jsonSchema<HelloWorldInput>({
type: 'object',
properties: {
includeTimestamp: {
type: 'boolean',
description: 'Whether to include a timestamp in the response',
},
},
additionalProperties: false,
}),
async execute({ includeTimestamp = true }) {
const response: any = {
message: 'Hello, World!',
};
if (includeTimestamp) {
response.timestamp = new Date().toISOString();
}
return response;
},
});
Key Changes
- Import from
ai: AddedjsonSchemaandtoolimports - Define TypeScript types: Created
HelloWorldInputtype for type safety - Use
tool()wrapper: Wraps the entire tool definition - Use
jsonSchema()for schema: Provides explicit JSON Schema withtype: "object"at root - Removed Zod dependency: No longer need
zodin package.json
Benefits
- ✅ Works with OpenAI's strict schema validation
- ✅ Explicit control over JSON Schema structure
- ✅ Full TypeScript type safety with generic types
- ✅ No dependency on Zod (one less package to maintain)
- ✅ Follows AI SDK v6 best practices
- ✅ Guaranteed
type: "object"at root level
Updated Package Dependencies
{
"dependencies": {
"ai": "6.0.0-beta.124"
}
}
Zod is no longer needed in tool packages that use jsonSchema().