Plugin Factory Guide
This guide explains how to properly write OpenPets plugins using the centralized plugin factory.
Table of Contents
- Quick Start
- Core Concepts
- Tool Definition
- Schema Definition
- Best Practices
- Common Patterns
- Troubleshooting
Quick Start
Minimal Plugin Example
import { z, createPlugin, type ToolDefinition } from "@/core/plugin-factory"
import { createLogger } from "@/core/logger"
const logger = createLogger("my-pet")
export const MyPetPlugin = async () => {
const tools: ToolDefinition[] = [
{
name: "my-tool",
description: "Does something useful",
schema: z.object({
message: z.string().describe("The message to process")
}),
async execute(args) {
logger.info("Executing my-tool", { message: args.message })
return JSON.stringify({ result: "success" })
}
}
]
return createPlugin(tools)
}
export default MyPetPluginCore Concepts
1. Zod Import
CRITICAL: Always import z from @/core/plugin-factory, never directly from "zod":
// ✅ CORRECT
import { z, createPlugin, type ToolDefinition } from "@/core/plugin-factory"
// ❌ WRONG
import { z } from "zod"
import { createPlugin, type ToolDefinition } from "@/core/plugin-factory"Why? The plugin factory exports the exact Zod instance that the OpenPets runtime uses internally (tool.schema). Using a different instance will cause schema validation errors.
2. Plugin Factory
The plugin factory provides two main functions:
createPlugin(tools: ToolDefinition[])
Use this for plugins with multiple tools:
export const MyPlugin = async () => {
const tools: ToolDefinition[] = [
{ name: "tool1", description: "...", schema: z.object({...}), execute: async (args) => {...} },
{ name: "tool2", description: "...", schema: z.object({...}), execute: async (args) => {...} },
]
return createPlugin(tools)
}createSingleTool(definition: Omit<ToolDefinition, 'name'>)
Use this for plugins with a single tool:
export const MySingleToolPlugin = async () => {
return {
tool: {
"my-tool": createSingleTool({
description: "Does one thing well",
schema: z.object({ input: z.string() }),
execute: async (args) => { ... }
})
}
}
}Tool Definition
Every tool must have these four properties:
1. name (string)
The unique identifier for the tool. Use kebab-case with a prefix:
// ✅ Good names
"jira-create-issue"
"polar-get-product"
"wise-get-transactions"
// ❌ Bad names
"createIssue" // No prefix
"jira_create_issue" // Use kebab-case, not snake_case
"create-issue" // Missing namespace prefix2. description (string)
A clear, concise description of what the tool does:
// ✅ Good descriptions
"Create a new Jira issue with specified fields"
"Get all products from Polar with optional filtering"
"Search for transactions by date range and status"
// ❌ Bad descriptions
"Creates issue" // Too vague
"This tool creates a new issue in Jira with the provided summary, description, and other fields" // Too verbose3. schema (ZodObject)
Defines the input parameters using Zod. Must be a z.object():
// ✅ CORRECT - Always use z.object()
schema: z.object({
message: z.string().describe("The message to send"),
priority: z.enum(["low", "medium", "high"]).optional().describe("Priority level"),
count: z.number().min(1).max(100).optional().describe("Number of items")
})
// ❌ WRONG - Don't use other Zod types at the root
schema: z.string() // Wrong!
schema: z.array(z.object({...})) // Wrong!Important: The factory automatically extracts schema.shape when passing tool definitions to the runtime. You don't need to do this manually.
4. execute (async function)
The function that implements the tool's logic:
async execute(args) {
// args is typed based on your schema
logger.info("Tool called", { args })
try {
// Do your work
const result = await someApiCall(args.message)
// MUST return a string (usually JSON)
return JSON.stringify({ success: true, result })
} catch (error) {
logger.error("Tool failed", { error })
return JSON.stringify({
success: false,
error: error instanceof Error ? error.message : String(error)
})
}
}Requirements:
- Must be
asyncor return aPromise<string> - Must return a
string(typically JSON) - Should handle errors gracefully
- Should log important events
Schema Definition
Basic Types
z.object({
// Strings
name: z.string().describe("User's name"),
email: z.string().email().describe("Email address"),
url: z.string().url().describe("Website URL"),
// Numbers
age: z.number().min(0).max(150).describe("Age in years"),
price: z.number().positive().describe("Price in USD"),
// Booleans
active: z.boolean().describe("Whether active"),
// Enums
status: z.enum(["pending", "active", "inactive"]).describe("Account status"),
// Dates
createdAt: z.string().describe("ISO 8601 date string"),
// Arrays (ONLY simple types!)
tags: z.array(z.string()).describe("List of tags"),
counts: z.array(z.number()).describe("List of counts"),
// For complex arrays, use JSON strings:
itemsJson: z.string().describe("JSON array of items. Example: '[{\"id\":\"123\",\"name\":\"Item\"}]'")
})Optional Fields
z.object({
required: z.string().describe("This field is required"),
optional: z.string().optional().describe("This field is optional"),
// With defaults
status: z.string().default("pending").describe("Status (defaults to pending)"),
// With fallback
count: z.number().catch(10).describe("Count (fallback to 10 on error)")
})Complex Schemas
z.object({
// Simple unions (primitive types only!)
identifier: z.union([
z.string(),
z.number()
]).describe("String or number ID"),
// Enums for type selection
notificationType: z.enum(["email", "sms"]).describe("Notification type"),
notificationAddress: z.string().describe("Email address or phone number"),
// Refinements (validation rules)
password: z.string()
.min(8)
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.describe("Password (min 8 chars, must include upper, lower, and digit)"),
// For complex nested data, use JSON:
addressJson: z.string().describe("JSON object with address. Example: '{\"street\":\"123 Main\",\"city\":\"NYC\"}'")
})⚠️ IMPORTANT: Schema Limitations
OpenPets runtime schemas do NOT support deeply nested schemas. Use simple types only:
✅ ALLOWED:
z.string()
z.number()
z.boolean()
z.array(z.string()) // Simple arrays OK
z.array(z.number())
z.object({ field: z.string() }) // Flat objects OK❌ NOT ALLOWED (will cause runtime errors):
z.array(z.object({...})) // Nested object in array
z.object({ nested: z.object({...}) }) // Nested object in object
z.record(z.string()) // Record type
z.union([z.object({...}), ...]) // Union with complex typesWORKAROUND: Use JSON strings for complex data:
// Instead of:
products: z.array(z.object({
name: z.string(),
price: z.number()
}))
// Do this:
productsJson: z.string().describe("JSON array of products. Example: '[{\"name\":\"Item\",\"price\":99}]'")
// Then parse in execute:
async execute(args) {
const products = JSON.parse(args.productsJson)
// ... process products
}Descriptions
Always add .describe() to your fields for better AI understanding:
// ✅ GOOD - Clear descriptions
z.object({
issueKey: z.string().describe("Jira issue key (e.g., PROJ-123)"),
maxResults: z.number().min(1).max(100).optional().describe("Maximum number of results (default: 50)"),
includeArchived: z.boolean().optional().describe("Include archived items in results (default: false)")
})
// ❌ BAD - No descriptions
z.object({
issueKey: z.string(),
maxResults: z.number().min(1).max(100).optional(),
includeArchived: z.boolean().optional()
})Best Practices
1. Use Logger
Always use the logger instead of console.log:
import { createLogger } from "@/core/logger"
const logger = createLogger("my-pet")
// ✅ Good
logger.info("Creating user", { email: args.email })
logger.error("Failed to create user", { error, email: args.email })
logger.debug("API response", { response })
// ❌ Bad
console.log("Creating user:", args.email)
console.error("Failed:", error)2. Return JSON Strings
Always return properly formatted JSON:
// ✅ Good
return JSON.stringify({
success: true,
data: result,
message: "Operation completed"
}, null, 2)
// ❌ Bad
return result // Not a string!
return JSON.stringify(result) // Works but not formatted
return "Success" // Not structured data3. Handle Errors Gracefully
async execute(args) {
try {
const result = await apiCall(args)
return JSON.stringify({ success: true, data: result })
} catch (error) {
logger.error("API call failed", {
error: error instanceof Error ? error.message : String(error),
args
})
return JSON.stringify({
success: false,
error: error instanceof Error ? error.message : "Unknown error",
details: error instanceof Error ? error.stack : undefined
}, null, 2)
}
}4. Use Environment Variables
import * as dotenv from "dotenv"
import * as path from "path"
export const MyPlugin = async () => {
dotenv.config({ path: path.join(__dirname, '.env') })
const API_KEY = process.env.MY_API_KEY
const BASE_URL = process.env.MY_BASE_URL?.replace(/\/$/, '')
if (!API_KEY || !BASE_URL) {
throw new Error("MY_API_KEY and MY_BASE_URL environment variables are required in .env file")
}
// ... rest of plugin
}5. Validate Required Config Early
export const MyPlugin = async () => {
const logger = createLogger("my-plugin")
// Load and validate config first
dotenv.config({ path: path.join(__dirname, '.env') })
const API_KEY = process.env.API_KEY
if (!API_KEY) {
logger.error("Missing API_KEY environment variable")
throw new Error("API_KEY environment variable is required in .env file")
}
logger.info("Initializing plugin", { hasApiKey: !!API_KEY })
// ... define tools
}Common Patterns
Pattern 1: API Client Plugin
import { z, createPlugin, type ToolDefinition } from "@/core/plugin-factory"
import { createLogger } from "@/core/logger"
import * as dotenv from "dotenv"
import * as path from "path"
const logger = createLogger("api-pet")
export const ApiPetPlugin = async () => {
dotenv.config({ path: path.join(__dirname, '.env') })
const API_KEY = process.env.API_KEY
const BASE_URL = process.env.BASE_URL?.replace(/\/$/, '')
if (!API_KEY || !BASE_URL) {
logger.error("Missing required environment variables")
throw new Error("API_KEY and BASE_URL are required in .env file")
}
// Shared API call helper
const makeRequest = async (endpoint: string, options: RequestInit = {}) => {
const url = `${BASE_URL}${endpoint}`
logger.debug("Making API request", { endpoint })
const response = await fetch(url, {
...options,
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
...options.headers
}
})
if (!response.ok) {
const error = await response.text()
logger.error("API request failed", { endpoint, status: response.status, error })
throw new Error(`API error: ${response.status} - ${error}`)
}
return response.json()
}
const tools: ToolDefinition[] = [
{
name: "api-get-item",
description: "Get an item by ID",
schema: z.object({
id: z.string().describe("Item ID")
}),
async execute(args) {
const data = await makeRequest(`/items/${args.id}`)
return JSON.stringify(data, null, 2)
}
},
{
name: "api-create-item",
description: "Create a new item",
schema: z.object({
name: z.string().describe("Item name"),
description: z.string().optional().describe("Item description")
}),
async execute(args) {
const data = await makeRequest('/items', {
method: 'POST',
body: JSON.stringify(args)
})
return JSON.stringify({ success: true, data }, null, 2)
}
}
]
return createPlugin(tools)
}
export default ApiPetPluginPattern 2: Database Plugin
import { z, createPlugin, type ToolDefinition } from "@/core/plugin-factory"
import { createLogger } from "@/core/logger"
import { Pool } from "pg"
const logger = createLogger("db-pet")
export const DbPetPlugin = async () => {
const pool = new Pool({
connectionString: process.env.DATABASE_URL
})
const tools: ToolDefinition[] = [
{
name: "db-query",
description: "Execute a SQL query",
schema: z.object({
query: z.string().describe("SQL query to execute"),
params: z.array(z.any()).optional().describe("Query parameters")
}),
async execute(args) {
try {
const result = await pool.query(args.query, args.params || [])
return JSON.stringify({
success: true,
rows: result.rows,
rowCount: result.rowCount
}, null, 2)
} catch (error) {
logger.error("Query failed", { error, query: args.query })
return JSON.stringify({
success: false,
error: error instanceof Error ? error.message : String(error)
}, null, 2)
}
}
}
]
return createPlugin(tools)
}
export default DbPetPluginPattern 3: File System Plugin
import { z, createPlugin, type ToolDefinition } from "@/core/plugin-factory"
import { createLogger } from "@/core/logger"
import { promises as fs } from "fs"
import * as path from "path"
const logger = createLogger("fs-pet")
export const FsPetPlugin = async () => {
const tools: ToolDefinition[] = [
{
name: "fs-read-file",
description: "Read file contents",
schema: z.object({
filePath: z.string().describe("Path to file"),
encoding: z.enum(["utf-8", "base64"]).optional().describe("File encoding (default: utf-8)")
}),
async execute(args) {
try {
const content = await fs.readFile(
args.filePath,
args.encoding || 'utf-8'
)
return JSON.stringify({
success: true,
content,
path: args.filePath
}, null, 2)
} catch (error) {
logger.error("Failed to read file", { error, path: args.filePath })
return JSON.stringify({
success: false,
error: error instanceof Error ? error.message : String(error)
}, null, 2)
}
}
}
]
return createPlugin(tools)
}
export default FsPetPluginTroubleshooting
Error: "undefined is not an object (evaluating 'schema._zod.def')"
Cause: You're importing Zod from the wrong place.
Fix:
// ❌ Wrong
import { z } from "zod"
// ✅ Correct
import { z } from "@/core/plugin-factory"Error: "Cannot find module '@/core/plugin-factory'"
Cause: TypeScript path mapping not configured.
Fix: Ensure your tsconfig.json has:
{
"compilerOptions": {
"paths": {
"@/*": ["../../src/*"]
}
}
}Tool Not Appearing
Checklist:
- Did you rebuild? Run
pnpm buildin your pet directory - Is your plugin exported as default?
export default MyPlugin - Does
pets tools <pet-name>list the expected tool? - Are there any build errors? Check the build output
Schema Validation Failing
Cause: Schema doesn't match expected input format.
Debug:
async execute(args) {
logger.debug("Received args", { args, type: typeof args })
// ... rest of code
}Type Errors with ToolDefinition
Cause: Schema type doesn't match execute args.
Fix:
// Explicitly type your tools array
const tools: ToolDefinition[] = [
{
name: "my-tool",
description: "...",
schema: z.object({
message: z.string()
}),
async execute(args) {
// args is inferred as { message: string }
return JSON.stringify({ got: args.message })
}
}
]Summary
Golden Rules:
- ✅ Always import
zfrom@/core/plugin-factory - ✅ Always use
z.object()for your schema - ✅ Always add
.describe()to your fields - ✅ Always return a JSON string from
execute() - ✅ Always use the logger, not console
- ✅ Always validate environment variables early
- ✅ Always handle errors gracefully
Following these guidelines will ensure your plugins work correctly across OpenPets-supported clients and provide a great user experience!