Builder, Runner, Engine
Every entity you define is both whole and part. The three-layer model - Builder, Runner, Engine - is the mechanism that makes that possible. Your builder defines the complete spec of what something IS. A runner connects that spec to an execution environment. An engine executes the lifecycle internally. You define with the builder, run with the runner, and never touch the engine directly.
The three layers
| Layer | Role | Example types |
|---|---|---|
| Builder | Fluent API for defining an entity | CapabilityBuilder, OperationBuilder |
| Runner | IoC bootstrap + lifecycle hook | CapabilityRunnerBuilder, StewardRunnerBuilder |
| Engine | Internal execution lifecycle | CapabilityEngine, StewardEngine |
You interact directly with the Builder. The Runner connects your builder to an execution environment - it initializes IoC and hands the builder to the Engine. The Engine is internal infrastructure: it builds the module lazily, validates input and output against your schemas, resolves services, runs guardrail checks, and executes.
import { Capability, CapabilityRunner } from 'jsr:@fathym/steward/capabilities';
import { z } from 'jsr:@std/zod';
// ─── BUILDER ──────────────────────────────────────────────
// You write this. FormatText becomes a portable, reusable spec.
const FormatText = Capability('FormatText', 'Formats text in various styles')
.Input(z.object({
text: z.string().describe('Text to format'),
style: z.enum(['upper', 'lower', 'title']).describe('Format style'),
}))
.Output(z.object({ formatted: z.string().describe('Formatted result') }))
.Execute(async (ctx) => {
const { text, style } = ctx.Input;
const formatted = style === 'upper'
? text.toUpperCase()
: style === 'lower'
? text.toLowerCase()
: text.replace(/\b\w/g, (c) => c.toUpperCase());
return { formatted };
});
// Complete - a portable spec. Nothing has executed yet.
// ─── RUNNER ───────────────────────────────────────────────
// Connects the builder to an execution environment. Triggers the Engine.
const result = await CapabilityRunner(FormatText)
.Execute({ text: 'hello world', style: 'title' });
// ─── ENGINE (internal) ────────────────────────────────────
// You never write this. On first Execute(), the Engine:
// 1. Called FormatText.Build() → cached the module
// 2. Validated input against the Input schema
// 3. Resolved services (none here)
// 4. Ran guardrail checks (none here)
// 5. Called Execute(ctx) → produced { formatted: 'Hello World' }
// 6. Validated output against the Output schema
console.log(result.formatted); // Hello World
The Builder is the portable holon - a complete spec for what the entity IS,
independent of where it runs. When FormatText moves from a standalone test to
an Operation to a larger Steward, it's the same builder, the same spec, the same
guarantees.
Builders defer Build
The Runner expects a Buildable - something with a .Build() method - not a
pre-built module. Pass the builder directly:
// WRONG - builds immediately, loses lazy guarantees:
const module = MyCapability.Build();
CapabilityRunner(module).Execute({ ... }); // type error: expects Buildable
// CORRECT - builder passed directly, built on first use:
CapabilityRunner(MyCapability).Execute({ ... }); // ✓
The Engine defers .Build() until the first Execute() and caches the result.
That lazy-build pattern lets the same builder be registered in many places - a
test, an Operation, a Steward - without premature construction.
Types flow from schemas
Your Zod schemas are the single source of truth for both runtime validation and
TypeScript inference. Define .Input(z.object({ ... })) and TypeScript infers
ctx.Input. Define .Output(...) and it enforces the return type of
.Execute(). No cast, no as any.
const WordCounter = Capability('WordCounter', 'Counts words in text')
.Input(z.object({ text: z.string().describe('Text to count words in') }))
.Output(z.object({ count: z.number().describe('Word count') }))
.Execute(async (ctx) => {
const { text } = ctx.Input; // TypeScript: { text: string } - inferred, no cast
return { count: text.split(/\s+/).filter(Boolean).length };
});
.describe() on every field isn't optional ceremony - it's how AI agents read
your schema. Each description is a machine-readable annotation of your intent.
- Transcend and Include → - why a larger whole never erases the smaller one