12 KiB
Zod Schema Serialization Problem - Dynamic Tool Loading System
Architecture Overview
We have a dynamic tool loading system with the following architecture:
Next.js Playground (Vercel) → Railway Deno Service → esm.sh CDN → npm packages
↓
AI SDK (OpenAI)
Components:
- Next.js Playground (
apps/playground) - Runs on Vercel, hosts the AI chat interface - Railway Deno Service (
apps/railway-executor/server.ts) - Runs on Railway, uses Deno's native HTTP import support - Dynamic Tool Loader (
apps/playground/src/lib/dynamic-tool-loader.ts) - Client-side code that calls Railway
Why We Need This Architecture:
- Next.js/Turbopack intercepts all
import()calls and cannot import from HTTP URLs (likehttps://esm.sh/package@version) - Deno natively supports HTTP imports via
import('https://...') - Tools are published to npm and loaded dynamically at runtime via esm.sh CDN
- We cannot bundle tools at build time - they must be discovered and loaded dynamically
The Problem
AI SDK tools require a specific format with description and inputSchema:
// AI SDK v6 Tool Format
const tool = {
description: "Search the web for current information...",
inputSchema: z.object({
query: z.string().describe("Search query"),
numResults: z.number().optional(),
}),
execute: async (params) => {
// ... execution logic
}
}
The Challenge: We need to send tool definitions from Railway (Deno) to the Playground (Next.js) over HTTP, but Zod schemas cannot be JSON serialized.
Error 1: def.shape is not a function
When we tried to send the Zod schema directly:
// Railway server.ts - DOESN'T WORK
return Response.json({
success: true,
tool: {
exportName: "searchTool",
description: "...",
inputSchema: toolModule.inputSchema, // ❌ Zod schema object
}
});
The Zod schema object loses all its methods during JSON serialization, causing:
TypeError: def.shape is not a function
Error 2: OpenAI Invalid Schema Error
When we removed inputSchema entirely:
// dynamic-tool-loader.ts - DOESN'T WORK
const tool = {
description: data.tool.description,
// No inputSchema
execute: async (params) => { ... }
};
OpenAI API rejects the tool:
Error [AI_APICallError]: Invalid schema for function 'firecrawl-aisdk-searchTool':
schema must be a JSON Schema of 'type: "object"', got 'type: "None"'.
Requirements
- ✅ Must work with AI SDK v6 - Tools must have
description+inputSchemaformat - ✅ Must serialize over HTTP - Railway → Playground communication is via fetch/HTTP
- ✅ Must support any Zod schema - Tools use various Zod types (objects, arrays, unions, etc.)
- ✅ Must preserve validation - The schema needs to work for OpenAI's function calling
- ✅ Must be efficient - Schemas should be cached, not re-serialized on every request
Current Code
Railway Server (apps/railway-executor/server.ts)
async function loadAndDescribe(req: Request): Promise<Response> {
const { packageName, exportName, version, importUrl } = await req.json();
const cacheKey = `${packageName}::${exportName}`;
let toolModule;
if (moduleCache.has(cacheKey)) {
toolModule = moduleCache.get(cacheKey);
} else {
const url = importUrl || `https://esm.sh/${packageName}@${version}`;
const module = await import(url); // Deno supports HTTP imports!
toolModule = module[exportName];
if (!toolModule.description || !toolModule.execute) {
return Response.json({
success: false,
error: 'Invalid AI SDK tool structure'
}, { status: 400 });
}
moduleCache.set(cacheKey, toolModule);
}
// ❌ PROBLEM: How to serialize inputSchema?
return Response.json({
success: true,
tool: {
exportName,
description: toolModule.description,
// Need to send inputSchema here somehow
hasInputSchema: !!toolModule.inputSchema,
},
});
}
async function executeTool(req: Request): Promise<Response> {
const { packageName, exportName, version, importUrl, params } = await req.json();
const cacheKey = `${packageName}::${exportName}`;
let toolModule;
if (moduleCache.has(cacheKey)) {
toolModule = moduleCache.get(cacheKey);
} else {
const url = importUrl || `https://esm.sh/${packageName}@${version}`;
const module = await import(url);
toolModule = module[exportName];
moduleCache.set(cacheKey, toolModule);
}
const result = await toolModule.execute(params || {});
return Response.json({
success: true,
output: result,
});
}
Dynamic Tool Loader (apps/playground/src/lib/dynamic-tool-loader.ts)
export async function loadToolDynamically(
packageName: string,
exportName: string,
version: string,
importUrl?: string
): Promise<any | null> {
const cacheKey = getCacheKey(packageName, exportName);
if (moduleCache.has(cacheKey)) {
return moduleCache.get(cacheKey);
}
// Call Railway to load and describe tool
const response = await fetch(`${RAILWAY_SERVICE_URL}/load-and-describe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
packageName,
exportName,
version,
importUrl: importUrl || `https://esm.sh/${packageName}@${version}`,
}),
});
const data = await response.json();
// ❌ PROBLEM: How to reconstruct inputSchema?
const tool = {
description: data.tool.description,
// Need inputSchema here for OpenAI API
execute: async (params: any) => {
const execResponse = await fetch(`${RAILWAY_SERVICE_URL}/execute-tool`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ packageName, exportName, version, importUrl, params }),
});
const result = await execResponse.json();
return result.output;
},
};
moduleCache.set(cacheKey, tool);
return tool;
}
Example Tool Schema
Here's an example of what we need to serialize:
import { tool, jsonSchema } from 'ai';
import { z } from 'zod';
// AI SDK v6 format
export const searchTool = tool({
description: 'Search the web for current information...',
inputSchema: jsonSchema<{
query: string;
numResults?: number;
category?: string;
}>({
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search query'
},
numResults: {
type: 'number',
description: 'Number of results',
minimum: 1,
maximum: 20,
default: 10
},
category: {
type: 'string',
enum: ['search', 'web-scraping', 'data-extraction'],
description: 'Filter by category'
}
},
required: ['query']
}),
execute: async ({ query, numResults = 10, category }) => {
// ... implementation
}
});
The inputSchema is created via jsonSchema<Type>() which wraps a JSON Schema object.
Potential Approaches to Consider
Option 1: Serialize Zod Schema to JSON Schema
Use zod-to-json-schema library to convert Zod schemas to JSON Schema format:
import { zodToJsonSchema } from 'zod-to-json-schema';
// In Railway server
const jsonSchema = zodToJsonSchema(toolModule.inputSchema);
return Response.json({ inputSchema: jsonSchema });
Pros:
- Standard approach
- Widely used library
- Preserves validation rules
Cons:
- Adds dependency to Railway service
- May not work with
jsonSchema()wrapper from AI SDK - Need to ensure it works with AI SDK v6 format
Option 2: Extract JSON Schema from AI SDK's jsonSchema()
The AI SDK's jsonSchema() function wraps a plain JSON Schema object. Maybe we can extract it:
// Investigate the structure of toolModule.inputSchema
console.log(JSON.stringify(toolModule.inputSchema, null, 2));
// Potentially:
const plainSchema = toolModule.inputSchema._def?.schema || toolModule.inputSchema;
Pros:
- No additional dependencies
- Uses the schema exactly as AI SDK expects it
Cons:
- Relies on internal structure (fragile)
- May break with AI SDK updates
Option 3: Store Schema Separately and Reconstruct
Store the raw JSON Schema definition separately in the tool package:
// In tool package
export const searchToolSchema = {
type: 'object',
properties: { ... }
};
export const searchTool = tool({
description: '...',
inputSchema: jsonSchema(searchToolSchema),
execute: async (params) => { ... }
});
Then send searchToolSchema over HTTP and reconstruct.
Pros:
- Clean separation
- Easy to serialize
Cons:
- Requires tool authors to export schema separately
- Duplication of schema definition
- Breaks existing tools
Option 4: Import Tool Directly in Playground (If Possible)
Try to make HTTP imports work in Next.js somehow:
- Webpack configuration hacks?
- Dynamic imports with custom loader?
- Build-time bundling of tools?
Pros:
- No serialization needed
- Direct access to tool objects
Cons:
- Next.js/Turbopack limitations are fundamental
- Defeats the purpose of dynamic loading
- May not be technically possible
Option 5: Two-Way Communication - Send Schema Back
Instead of Railway → Playground, have Playground → Railway for schema:
- Playground asks Railway: "Does this tool exist?"
- Railway responds: "Yes, here's the description"
- Playground asks Railway: "Execute this tool with these params"
- Railway validates params against schema and executes
The schema stays on Railway side, never serialized.
Pros:
- Schema never leaves Railway
- No serialization issues
Cons:
- OpenAI API requires schema upfront for function calling
- Can't defer schema to execution time
- Doesn't solve the core problem
Questions for Consideration
-
Can we use
zod-to-json-schemawith AI SDK v6'sjsonSchema()wrapper?- How does
jsonSchema()work internally? - Does it already store a plain JSON Schema somewhere?
- How does
-
Does the AI SDK provide any serialization utilities?
- Is there a built-in way to serialize tool definitions?
- Does Vercel have examples of this pattern?
-
Can we modify the tool format to make serialization easier?
- Would it break compatibility with existing tools?
- Is there a standardized way tools should export schemas?
-
Is there a way to inspect the AI SDK's jsonSchema() structure?
- What properties does it have?
- Can we extract the underlying JSON Schema object safely?
-
Should we contribute back to the ecosystem?
- Is this a common problem?
- Should there be a standard for serializable AI SDK tools?
Current Status
- ✅ Railway service successfully imports tools from esm.sh via HTTP
- ✅ Railway can execute tools and return results
- ✅ Module caching works on Railway side
- ✅ Tool wrapper caching works on Playground side
- ❌ Cannot serialize Zod schemas over HTTP (this blocker)
- ❌ OpenAI API rejects tools without proper inputSchema
Files to Reference
apps/railway-executor/server.ts- Railway Deno serviceapps/playground/src/lib/dynamic-tool-loader.ts- Tool loader clientpackages/tools/search-registry/src/index.ts- Example tool using AI SDK v6
Success Criteria
We need a solution that:
- Sends complete tool definitions (description + inputSchema + execute capability) from Railway to Playground
- Works with OpenAI's function calling API (requires valid JSON Schema)
- Supports all Zod schema types used by AI SDK tools
- Is maintainable and doesn't break with AI SDK updates
- Doesn't require changes to existing published tool packages (if possible)