Fathym
Menu

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

LayerRoleExample types
BuilderFluent API for defining an entityCapabilityBuilder, OperationBuilder
RunnerIoC bootstrap + lifecycle hookCapabilityRunnerBuilder, StewardRunnerBuilder
EngineInternal execution lifecycleCapabilityEngine, 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.


On this page