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 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).
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.
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.
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
| Function | Purpose |
|---|---|
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 orderPlugin 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
| Issue | Cause | Solution |
|---|---|---|
| Capability not found | Not exported or wrong ID | Verify export and ID match |
| Validation error | Type mismatch | Check Zod schema matches data |
| Multiple implementations | Same ID used twice | Use unique IDs or namespace properly |
Debug Mode
// Enable detailed logging
const results = await call(plugin, requests, {
debug: true // Logs scanning and execution details
});Related Documentation
- Zod Documentation - Schema validation
- TypeScript Handbook - Type system
- Plugin Architecture Patterns - Design patterns
How is this guide?
Last updated on