Spry LogoOpsfolio
Contributing and Support

Extensions Module

Core concepts, purpose, and workflow for using the spry extensions system

Core Concept

Spry Extensions provide a mechanism to define and execute "capabilities" (functions) across the system in a decoupled, type-safe manner.

Key Characteristics

  • Registry-Free: There is no central list of plugins. Capabilities are discovered by scanning module exports.
  • Runtime Safety: All inputs and outputs are validated at runtime using Zod.
  • Decoupling: Callers invoke functions by a stable String ID, not by importing a specific function. This allows implementations to be swapped or plugged in dynamically.

Purpose

The module solves the problem of "how do I allow others to extend my system safely?"

For Hosts (The System)

You can run code from plugins without knowing where it comes from, while guaranteeing it matches a specific signature (inputs/outputs) and won't crash your app with bad types.

For Plugin Authors

You export simple functions. No complex classes, decorators, or config files required.

For AI/Automation

Capabilities are self-describing and discoverable, making it easy for agents to know what tools are available.

Workflow & Usage

There are three main steps to using Spry Extensions: Define, Implement, and Execute.

Define (The Contract)

Create a Callable Definition. This establishes the "interface" — the ID and the expected types.

This is usually done in a shared module (e.g., defs.ts).

defs.ts
import * as z from "@zod/zod";
import { callableDefn } from "./lib/extend/extension.ts";

// Define the contract
export const addDef = callableDefn(
  "spry.math.add", // Stable ID
  { 
    input: [z.number(), z.number()], 
    output: z.number() 
  }
);

The stable ID serves as the contract identifier across the system, enabling runtime discovery.

Implement (The Behavior)

Create a Callable Implementation. This provides the actual logic.

This can be in any module that the host will scan.

my-math-plugin.ts
import { callable } from "./lib/extend/extension.ts";
import { addDef } from "./defs.ts";

// Strict types are inferred from 'addDef'
export const addImpl = callable(addDef, (a, b) => {
  return a + b;
});

You must export the implementation for it to be discovered by the scanner.

Execute (The Host)

The host application scans for implementations and executes them.

host.ts
import { call } from "./lib/extend/extension.ts";
import * as myMathPlugin from "./my-math-plugin.ts"; // or dynamic import

// 1. Scan & Execute
const results = await call(
  myMathPlugin, // Object containing exports to scan
  [
    { id: "spry.math.add", args: [10, 20] }
  ]
);

// 2. Handle Results
results.forEach(result => {
  if (result.ok) {
    console.log("Result:", result.value); // 30
  } else {
    console.error("Error:", result.error);
  }
});

API Reference

Core Functions

FunctionPurpose
callableDefn(id, schema)Create a named contract (definition).
callable(defn, implFn)Wrap a function to enforce the contract (implementation).
scanCallables(module)Find all callable implementations in a module.
call(module, requests)Find and execute capabilities by ID.

Type Safety

// Inputs are validated at runtime using Zod
const addDef = callableDefn(
  "spry.math.add",
  { 
    input: [z.number(), z.number()], 
    output: z.number() 
  }
);

// Invalid inputs will be caught at runtime
call(plugin, [
  { id: "spry.math.add", args: ["10", "20"] } // Error!
]);
// Outputs are also validated
export const badImpl = callable(addDef, (a, b) => {
  return "not a number"; // Runtime error!
});
// TypeScript infers types from the definition
export const addImpl = callable(addDef, (a, b) => {
  // a: number, b: number (inferred)
  return a + b; // Must return number
});

Advanced Usage

Multiple Capabilities

// Define multiple capabilities
export const mathDefs = {
  add: callableDefn("spry.math.add", {
    input: [z.number(), z.number()],
    output: z.number()
  }),
  multiply: callableDefn("spry.math.multiply", {
    input: [z.number(), z.number()],
    output: z.number()
  })
};

// Implement all capabilities
export const mathPlugin = {
  add: callable(mathDefs.add, (a, b) => a + b),
  multiply: callable(mathDefs.multiply, (a, b) => a * b)
};

Error Handling

const results = await call(plugin, requests);

results.forEach(result => {
  if (result.ok) {
    // Success case
    console.log(result.value);
  } else {
    // Error case
    console.error(result.error);
  }
});
// Common error scenarios:
// - Capability not found
// - Input validation failed
// - Output validation failed
// - Implementation threw error

if (!result.ok) {
  switch (result.error.type) {
    case "not-found":
      console.log("Capability not available");
      break;
    case "validation":
      console.log("Invalid input/output");
      break;
    default:
      console.log("Execution error");
  }
}

Dynamic Plugin Loading

// Load plugins dynamically
async function loadPlugin(path: string) {
  const module = await import(path);
  return module;
}

// Use loaded plugin
const plugin = await loadPlugin("./plugins/my-plugin.ts");
const results = await call(plugin, requests);

Best Practices

Naming Conventions

// Use dot-separated namespaces
// Good
"spry.math.add"
"spry.data.fetch"
"myapp.core.process"

// Avoid
"addNumbers"
"math_add"
// Suffix with 'Def' or 'Defn'
export const addDef = callableDefn(...);
export const fetchDef = callableDefn(...);
// Suffix with 'Impl' or describe the variant
export const addImpl = callable(...);
export const addSimple = callable(...);
export const addOptimized = callable(...);

Schema Design

// Be specific with schemas
const goodDef = callableDefn("app.user.create", {
  input: [
    z.object({
      name: z.string().min(1),
      email: z.string().email(),
      age: z.number().int().positive().optional()
    })
  ],
  output: z.object({
    id: z.string().uuid(),
    createdAt: z.date()
  })
});

// Avoid overly permissive schemas
const badDef = callableDefn("app.user.create", {
  input: [z.any()], // Too loose!
  output: z.unknown() // No validation!
});

Always use specific Zod schemas. Avoid z.any() and z.unknown() unless truly necessary.

Module Organization

// 1. Group definitions by domain
// defs/math.ts
export const mathDefs = { ... };

// defs/data.ts
export const dataDefs = { ... };

// 2. Keep implementations separate
// impl/math.ts
export const mathImpl = { ... };

// impl/data.ts
export const dataImpl = { ... };

// 3. Re-export for convenience
// index.ts
export * from "./defs/math.ts";
export * from "./impl/math.ts";

Testing

Testing Definitions

import { assertEquals } from "std/assert/mod.ts";
import { describe, it } from "std/testing/bdd.ts";

describe("Math Definitions", () => {
  it("should have correct schema", () => {
    const def = addDef;
    assertEquals(def.id, "spry.math.add");
    // Schema validation tests
  });
});

Testing Implementations

import { call } from "./lib/extend/extension.ts";
import * as mathPlugin from "./math-plugin.ts";

describe("Math Plugin", () => {
  it("should add numbers correctly", async () => {
    const results = await call(mathPlugin, [
      { id: "spry.math.add", args: [5, 3] }
    ]);
    
    assertEquals(results[0].ok, true);
    if (results[0].ok) {
      assertEquals(results[0].value, 8);
    }
  });
  
  it("should reject invalid inputs", async () => {
    const results = await call(mathPlugin, [
      { id: "spry.math.add", args: ["5", "3"] }
    ]);
    
    assertEquals(results[0].ok, false);
  });
});

Migration Guide

From Direct Imports

// Old: Direct coupling
import { add } from "./math.ts";

const result = add(10, 20);
// New: Extension-based
import { call } from "./lib/extend/extension.ts";
import * as mathPlugin from "./math-plugin.ts";

const results = await call(mathPlugin, [
  { id: "spry.math.add", args: [10, 20] }
]);

From Class-Based Plugins

// Old: Complex plugin system
class MathPlugin extends Plugin {
  register() { ... }
  add(a: number, b: number) { ... }
}
// New: Simple exports
export const add = callable(addDef, (a, b) => {
  return a + b;
});

Common Patterns

Capability Discovery

// List all available capabilities
function discoverCapabilities(module: unknown): string[] {
  const callables = scanCallables(module);
  return callables.map(c => c.id);
}

const available = discoverCapabilities(plugin);
console.log("Available:", available);

Batch Execution

// Execute multiple capabilities at once
const requests = [
  { id: "spry.math.add", args: [1, 2] },
  { id: "spry.math.multiply", args: [3, 4] },
  { id: "spry.math.add", args: [5, 6] }
];

const results = await call(plugin, requests);
// Results maintain order

Plugin Composition

// Combine multiple plugins
const combinedPlugin = {
  ...mathPlugin,
  ...dataPlugin,
  ...utilPlugin
};

const results = await call(combinedPlugin, requests);

Type Safety Throughout

All functions provide full TypeScript type inference, ensuring compile-time safety alongside runtime validation.

Troubleshooting

Common Issues

IssueCauseSolution
Capability not foundNot exported or wrong IDVerify export and ID match
Validation errorType mismatchCheck Zod schema matches data
Multiple implementationsSame ID used twiceUse unique IDs or namespace properly

Debug Mode

// Enable detailed logging
const results = await call(plugin, requests, {
  debug: true // Logs scanning and execution details
});

How is this guide?

Last updated on

On this page