Skip to content

Plugin Factory Guide

This guide explains how to properly write OpenPets plugins using the centralized plugin factory.

Table of Contents

Quick Start

Minimal Plugin Example

typescript
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 MyPetPlugin

Core Concepts

1. Zod Import

CRITICAL: Always import z from @/core/plugin-factory, never directly from "zod":

typescript
// ✅ 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:

typescript
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:

typescript
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:

typescript
// ✅ 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 prefix

2. description (string)

A clear, concise description of what the tool does:

typescript
// ✅ 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 verbose

3. schema (ZodObject)

Defines the input parameters using Zod. Must be a z.object():

typescript
// ✅ 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:

typescript
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 async or return a Promise<string>
  • Must return a string (typically JSON)
  • Should handle errors gracefully
  • Should log important events

Schema Definition

Basic Types

typescript
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

typescript
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

typescript
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:

typescript
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):

typescript
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 types

WORKAROUND: Use JSON strings for complex data:

typescript
// 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:

typescript
// ✅ 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:

typescript
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:

typescript
// ✅ 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 data

3. Handle Errors Gracefully

typescript
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

typescript
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

typescript
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

typescript
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 ApiPetPlugin

Pattern 2: Database Plugin

typescript
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 DbPetPlugin

Pattern 3: File System Plugin

typescript
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 FsPetPlugin

Troubleshooting

Error: "undefined is not an object (evaluating 'schema._zod.def')"

Cause: You're importing Zod from the wrong place.

Fix:

typescript
// ❌ 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:

json
{
  "compilerOptions": {
    "paths": {
      "@/*": ["../../src/*"]
    }
  }
}

Tool Not Appearing

Checklist:

  1. Did you rebuild? Run pnpm build in your pet directory
  2. Is your plugin exported as default? export default MyPlugin
  3. Does pets tools <pet-name> list the expected tool?
  4. Are there any build errors? Check the build output

Schema Validation Failing

Cause: Schema doesn't match expected input format.

Debug:

typescript
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:

typescript
// 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:

  1. ✅ Always import z from @/core/plugin-factory
  2. ✅ Always use z.object() for your schema
  3. ✅ Always add .describe() to your fields
  4. ✅ Always return a JSON string from execute()
  5. ✅ Always use the logger, not console
  6. ✅ Always validate environment variables early
  7. ✅ Always handle errors gracefully

Following these guidelines will ensure your plugins work correctly across OpenPets-supported clients and provide a great user experience!