Stack Templates
Templates for creating @outfitter/* components.
Component Types
Type Package Template
Handler @outfitter/contracts
handler
Handler Test @outfitter/testing
handler-test
CLI Command @outfitter/cli
cli-command
MCP Tool @outfitter/mcp
mcp-tool
Daemon @outfitter/daemon
daemon-service
Handler
Transport-agnostic business logic returning Result<T, E> :
import { Result, ValidationError, NotFoundError, createValidator, type Handler, } from "@outfitter/contracts"; import { z } from "zod";
// 1. Input schema const InputSchema = z.object({ id: z.string().min(1), }); type Input = z.infer<typeof InputSchema>;
// 2. Output type interface Output { id: string; name: string; }
// 3. Validator const validateInput = createValidator(InputSchema);
// 4. Handler export const myHandler: Handler<unknown, Output, ValidationError | NotFoundError> = async ( rawInput, ctx ) => { const inputResult = validateInput(rawInput); if (inputResult.isErr()) return inputResult; const input = inputResult.value;
ctx.logger.debug("Processing", { id: input.id });
const resource = await fetchResource(input.id); if (!resource) { return Result.err(new NotFoundError("resource", input.id)); }
return Result.ok(resource); };
Handler Test
Test handlers directly without transport layer:
import { describe, test, expect } from "bun:test"; import { createContext } from "@outfitter/contracts"; import { myHandler } from "../handlers/my-handler.js";
describe("myHandler", () => { test("returns success for valid input", async () => { const ctx = createContext({}); const result = await myHandler({ id: "valid-id" }, ctx);
expect(result.isOk()).toBe(true);
expect(result.value).toMatchObject({ id: "valid-id" });
});
test("returns NotFoundError for missing resource", async () => { const ctx = createContext({}); const result = await myHandler({ id: "missing" }, ctx);
expect(result.isErr()).toBe(true);
expect(result.error._tag).toBe("NotFoundError");
expect(result.error.resourceId).toBe("missing");
});
test("returns ValidationError for invalid input", async () => { const ctx = createContext({}); const result = await myHandler({ id: "" }, ctx);
expect(result.isErr()).toBe(true);
expect(result.error._tag).toBe("ValidationError");
}); });
CLI Command
Commander.js command calling a handler:
import { command, output, exitWithError } from "@outfitter/cli"; import { createContext } from "@outfitter/contracts"; import { myHandler } from "../handlers/my-handler.js";
export const myCommand = command("my-command") .description("What this command does") .argument("<id>", "Resource ID") .option("-l, --limit <n>", "Limit results", parseInt) .action(async ({ args, flags }) => { const ctx = createContext({});
const result = await myHandler({ id: args.id, limit: flags.limit }, ctx);
if (result.isErr()) {
exitWithError(result.error);
}
await output(result.value);
}) .build();
Register in CLI:
import { createCLI } from "@outfitter/cli"; import { myCommand } from "./commands/my-command.js";
const cli = createCLI({ name: "myapp", version: "1.0.0" }); cli.program.addCommand(myCommand); cli.program.parse();
MCP Tool
Use defineTool() for type-safe tool definitions with automatic schema inference:
import { defineTool } from "@outfitter/mcp"; import { Result, ValidationError } from "@outfitter/contracts"; import { z } from "zod";
const InputSchema = z.object({ query: z.string().describe("Search query"), limit: z.number().int().positive().default(10).describe("Max results"), });
interface Output { results: Array<{ id: string; title: string }>; total: number; }
export const myTool = defineTool({ name: "my_tool", description: "Tool description for AI agent", inputSchema: InputSchema,
handler: async (input): Promise<Result<Output, ValidationError>> => { // input is automatically typed as z.infer<typeof InputSchema> const results = await search(input.query, input.limit); return Result.ok({ results, total: results.length }); }, });
Register in server:
import { createMcpServer } from "@outfitter/mcp"; import { myTool } from "./tools/my-tool.js";
const server = createMcpServer({ name: "my-server", version: "0.1.0" }); server.registerTool(myTool); server.start();
Daemon Service
Background service with health checks and IPC:
import { createDaemon, createIpcServer, createHealthChecker, getSocketPath, getLockPath, } from "@outfitter/daemon"; import { createLogger, createConsoleSink } from "@outfitter/logging"; import { Result } from "@outfitter/contracts";
const logger = createLogger({ name: "my-daemon", level: "info", sinks: [createConsoleSink()], redaction: { enabled: true }, });
const daemon = createDaemon({ name: "my-daemon", pidFile: getLockPath("my-daemon"), logger, shutdownTimeout: 10000, });
const healthChecker = createHealthChecker([
{
name: "memory",
check: async () => {
const used = process.memoryUsage().heapUsed / 1024 / 1024;
return used < 500
? Result.ok(undefined)
: Result.err(new Error(High memory: ${used.toFixed(2)}MB));
},
},
]);
const ipcServer = createIpcServer(getSocketPath("my-daemon"));
ipcServer.onMessage(async (msg) => { const message = msg as { type: string }; switch (message.type) { case "status": return { status: "ok", uptime: process.uptime() }; case "health": return await healthChecker.check(); default: return { error: "Unknown command" }; } });
daemon.onShutdown(async () => { logger.info("Shutting down..."); await ipcServer.close(); });
async function main() { const startResult = await daemon.start(); if (startResult.isErr()) { logger.error("Failed to start", { error: startResult.error }); process.exit(1); } await ipcServer.listen(); logger.info("Started", { socket: getSocketPath("my-daemon") }); }
main();
Best Practices
-
Handler First - Write handler before adapter (CLI/MCP/API)
-
Validate Early - Use createValidator at handler entry
-
Type Errors - List all error types in handler signature
-
Context Propagation - Pass context through all handler calls
-
Test Handlers - Test handlers directly without transport layer
References
-
templates/handler.md
-
templates/handler-test.md
-
templates/cli-command.md
-
templates/mcp-tool.md
-
templates/daemon-service.md