Common conventions and idioms used across Outfitter packages. These patterns ensure consistency and interoperability between CLI, MCP, and API surfaces.
The handler contract is the core abstraction. Handlers are pure functions that:
- Accept typed input and context
- Return
Result<TOutput, TError> - Know nothing about transport (CLI flags, HTTP headers, etc.)
type Handler<
TInput,
TOutput,
TError extends OutfitterError = OutfitterError,
> = (input: TInput, ctx: HandlerContext) => Promise<Result<TOutput, TError>>;import {
Result,
NotFoundError,
type Handler,
type HandlerContext,
} from "@outfitter/contracts";
interface GetUserInput {
id: string;
}
interface User {
id: string;
name: string;
email: string;
}
export const getUser: Handler<GetUserInput, User, NotFoundError> = async (
input,
ctx
) => {
ctx.logger.debug("Fetching user", { userId: input.id });
const user = await db.users.findById(input.id);
if (!user) {
return Result.err(NotFoundError.create("user", input.id));
}
return Result.ok(user);
};- Testability — No mocking HTTP/CLI, just call the function
- Reusability — Same handler serves CLI, MCP, and HTTP
- Type Safety — Input, output, and error types are explicit
- Composability — Handlers can wrap other handlers
Structured logs are for diagnostics. User-facing output belongs to the transport adapter.
| Context | Use | Why |
|---|---|---|
| Handler internals | ctx.logger |
Structured traces with redaction |
| CLI success output | @outfitter/cli output() |
Respects --json/--jsonl modes |
| CLI errors | exitWithError() |
Typed formatting + exit codes |
| MCP tool output | Result.ok(data) |
Transport-agnostic responses |
| MCP diagnostics | ctx.logger |
Structured traces for debugging |
At the boundary (CLI/MCP/HTTP), create a logger once and inject it via createContext({ logger }).
Outfitter uses Result<T, E> from better-result for explicit error handling.
import { NotFoundError, Result } from "@outfitter/contracts";
// Success
const ok = Result.ok({ name: "Alice" });
// Failure
const err = Result.err(NotFoundError.create("user", "123"));if (result.isOk()) {
console.log(result.value); // TypeScript knows this is T
} else {
console.log(result.error); // TypeScript knows this is E
}const message = result.match({
ok: (user) => `Found ${user.name}`,
err: (error) => `Error: ${error.message}`,
});When you have multiple operations that might fail:
import { combine2, combine3 } from "@outfitter/contracts";
const result1 = await getUser({ id: "1" }, ctx);
const result2 = await getUser({ id: "2" }, ctx);
// Combine into tuple [User, User] or first error
const combined = combine2(result1, result2);
if (combined.isOk()) {
const [user1, user2] = combined.value;
}import { unwrapOrElse, orElse } from "@outfitter/contracts";
// Default value on error
const user = unwrapOrElse(result, () => defaultUser);
// Try alternative on error
const finalResult = orElse(primaryResult, fallbackResult);Ten error categories cover all failure modes. Each maps to exit codes and HTTP status. See the full taxonomy table in Architecture.
import {
ValidationError,
NotFoundError,
ConflictError,
InternalError,
} from "@outfitter/contracts";
// Validation error with details
ValidationError.create("email", "invalid format", {
value: "not-an-email",
});
// Not found with resource info
NotFoundError.create("user", "user-123");
// Conflict with existing resource
ConflictError.create("User already exists", {
resourceType: "user",
resourceId: "user-123",
});
// Internal error (wrap unexpected exceptions)
InternalError.create("Database connection failed", { cause: originalError });Adapters automatically map errors to appropriate codes:
import { getExitCode, getStatusCode } from "@outfitter/contracts";
const error = NotFoundError.create("user", "123");
getExitCode(error.category); // 2
getStatusCode(error.category); // 404switch (error._tag) {
case "ValidationError":
return {
status: 400,
body: { error: error.message, context: error.context },
};
case "NotFoundError":
return { status: 404, body: { error: `${error.resourceType} not found` } };
case "AuthError":
return { status: 401, body: { error: "Authentication required" } };
default:
return { status: 500, body: { error: "Internal error" } };
}Instead of sprinkling raw error constructors across handlers, define a small domain-specific factory that wraps the taxonomy with your language.
import {
ConflictError,
NotFoundError,
ValidationError,
} from "@outfitter/contracts";
export const UserErrors = {
notFound: (id: string) => NotFoundError.create("user", id),
emailTaken: (email: string) =>
ConflictError.create("Email already in use", { email }),
invalidEmail: (email: string) =>
ValidationError.create("email", "invalid", { value: email }),
};Then in handlers:
if (!user) return Result.err(UserErrors.notFound(userId));This keeps handlers small, consistent, and makes errors easy to discover.
When defining Outfitter actions, keep type declarations explicit at the export boundary, but avoid redundant generic ceremony inside the implementation.
import { defineAction } from "@outfitter/contracts";
import { z } from "zod";
interface ListInput {
readonly cwd: string;
readonly outputMode: "human" | "json" | "jsonl";
}
const listInputSchema = z.object({
cwd: z.string(),
outputMode: z.enum(["human", "json", "jsonl"]),
});
type ListAction = ReturnType<typeof defineAction<ListInput, unknown>>;
export const listAction: ListAction = defineAction({
id: "list",
surfaces: ["cli"],
input: listInputSchema,
cli: {
command: "list",
mapInput: (context) => ({
cwd: String(context.flags["cwd"] ?? process.cwd()),
outputMode: "human",
}),
},
handler: async (input) => runList(input),
});Guideline:
- Prefer
type ActionName = ReturnType<typeof defineAction<TInput, TOutput>>. - Keep one explicit exported action annotation via that alias.
- Use
as z.ZodType<T>only when required forisolatedDeclarationsstability.
Use Zod schemas with createValidator for type-safe input validation that returns Results.
import { createValidator, validateInput } from "@outfitter/contracts";
import { z } from "zod";
const UserInputSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().positive().optional(),
});
type UserInput = z.infer<typeof UserInputSchema>;
// Create a reusable validator
const validateUserInput = createValidator(UserInputSchema);
// Use it
const result = validateUserInput(rawInput);
if (result.isErr()) {
// result.error is ValidationError with structured context
console.log(result.error.context); // e.g. { issues: [...] }
}const result = validateInput(UserInputSchema, rawInput);export const createUser: Handler<
unknown,
User,
ValidationError | ConflictError
> = async (rawInput, ctx) => {
// Validate first
const inputResult = validateUserInput(rawInput);
if (inputResult.isErr()) {
return inputResult; // Pass through the ValidationError
}
const input = inputResult.value; // Now typed as UserInput
// Proceed with validated input
return Result.ok(await db.users.create(input));
};HandlerContext carries cross-cutting concerns through handler calls.
import { createContext, type HandlerContext } from "@outfitter/contracts";
const ctx = createContext({
logger: myLogger, // Optional, defaults to no-op
config: resolvedConfig, // Optional
signal: controller.signal, // Optional, for cancellation
workspaceRoot: "/project", // Optional
cwd: process.cwd(), // Optional, defaults to process.cwd()
env: process.env, // Optional
});| Field | Type | Description |
|---|---|---|
requestId |
string |
Auto-generated UUIDv7 for tracing |
logger |
Logger |
Structured logger instance |
config |
ResolvedConfig |
Merged configuration |
signal |
AbortSignal |
Cancellation signal |
workspaceRoot |
string |
Project root directory |
cwd |
string |
Current working directory |
env |
Record<string, string | undefined> |
Environment variables |
The requestId is auto-generated using Bun.randomUUIDv7(), which is time-sortable:
const ctx = createContext({});
console.log(ctx.requestId); // "018e4f3c-1a2b-7000-8000-000000000001"
// Use for logging correlation
ctx.logger.info("Processing request", { requestId: ctx.requestId });const controller = new AbortController();
const ctx = createContext({ signal: controller.signal });
// In handler
if (ctx.signal?.aborted) {
return Result.err(CancelledError.create("Operation cancelled"));
}
// To cancel from outside
controller.abort();Use @outfitter/schema when you need transport-agnostic action discovery, docs generation, or CI drift checks.
import { generateManifest } from "@outfitter/schema/manifest";
const manifest = generateManifest(registry, {
version: "1.0.0",
surface: "mcp",
});For direct schema conversion, use zodToJsonSchema() from @outfitter/contracts:
import { zodToJsonSchema } from "@outfitter/contracts";
import { z } from "zod";
const Input = z.object({
id: z.string(),
verbose: z.boolean().optional(),
});
const inputSchema = zodToJsonSchema(Input);import { diffSurfaceMaps } from "@outfitter/schema/diff";
import { generateSurfaceMap, readSurfaceMap } from "@outfitter/schema/surface";
const committed = await readSurfaceMap(".outfitter/snapshots/v1.0.0.json");
const current = generateSurfaceMap(registry, { version: "1.0.0" });
const diff = diffSurfaceMaps(committed, current);
if (diff.hasChanges) {
process.exit(1);
}For CLI-specific schema publication and command conventions, see CLI Conventions.
CLI output defaults to human-readable text. Machine-readable formats are opt-in.
import { output } from "@outfitter/cli/output";
await output(data); // Human by default- Explicit
modeoption inoutput()call OUTFITTER_JSONL=1environment variableOUTFITTER_JSON=1environment variableOUTFITTER_JSON=0orOUTFITTER_JSONL=0forces human mode- Default fallback: human mode
// Force JSON
await output(data, { mode: "json" });
// Force human-readable
await output(data, { mode: "human" });
// JSON Lines (for streaming)
await output(data, { mode: "jsonl" });await output(errorData, { stream: process.stderr });Pagination state persists between CLI invocations for --next functionality.
Cursor state is stored in XDG state directory:
$XDG_STATE_HOME/{toolName}/cursors/{command}/cursor.json
import { loadCursor, saveCursor, clearCursor } from "@outfitter/cli/pagination";
const options = { command: "list", toolName: "myapp" };
// Load previous cursor
const state = loadCursor(options);
// Fetch data
const results = await listItems({ cursor: state?.cursor, limit: 20 });
// Save for next time
if (results.hasMore) {
saveCursor(results.nextCursor, options);
}
// Reset on --reset flag
if (flags.reset) {
clearCursor(options);
}const state = loadCursor({
...options,
maxAgeMs: 30 * 60 * 1000, // Expire after 30 minutes
});Structured logging with automatic sensitive data redaction.
import { createLogger, createConsoleSink } from "@outfitter/logging";
const logger = createLogger({
name: "my-service",
level: "debug",
sinks: [createConsoleSink()],
redaction: { enabled: true },
});| Level | Use For |
|---|---|
trace |
Very detailed debugging |
debug |
Development debugging |
info |
Normal operations |
warn |
Unexpected but handled |
error |
Failures requiring attention |
fatal |
Unrecoverable failures |
logger.info("User created", {
userId: user.id,
email: user.email,
duration: performance.now() - start,
});const requestLogger = createChildLogger(logger, {
requestId: ctx.requestId,
handler: "createUser",
});
requestLogger.info("Processing"); // Includes requestId and handlerSensitive data is automatically redacted:
logger.info("Config loaded", {
apiKey: "secret-key-123", // Logged as "[REDACTED]"
database: { password: "secret" }, // Nested values also redacted
});User-provided paths are untrusted by default. Validate them or regret it later.
import { securePath, isPathSafe, resolveSafePath } from "@outfitter/file-ops";
// Validate user input
const result = securePath(userInput, "/app/workspace");
if (result.isErr()) {
// Path traversal attempt or invalid path
return Result.err(result.error);
}
const safePath = result.value; // Guaranteed within workspace| Input | Result |
|---|---|
../etc/passwd |
ValidationError (traversal) |
/etc/passwd |
ValidationError (absolute) |
file\x00.txt |
ValidationError (null byte) |
data/file.json |
OK, returns absolute path |
Prevent partial writes and corruption:
import { atomicWrite, atomicWriteJson } from "@outfitter/file-ops";
// Text content
await atomicWrite("/data/config.txt", content);
// JSON with auto-serialization
await atomicWriteJson("/data/config.json", { key: "value" });Coordinate access between processes:
import { withLock } from "@outfitter/file-ops";
const result = await withLock("/data/db.json", async () => {
const data = JSON.parse(await Bun.file("/data/db.json").text());
data.counter++;
await atomicWrite("/data/db.json", JSON.stringify(data));
return data.counter;
});XDG-compliant config loading with schema validation.
import { loadConfig } from "@outfitter/config";
import { z } from "zod";
const AppConfigSchema = z.object({
apiKey: z.string(),
timeout: z.number().default(5000),
});
const result = await loadConfig("myapp", AppConfigSchema);
// Searches: ~/.config/myapp/config.{toml,yaml,json}import { resolveConfig } from "@outfitter/config";
const result = resolveConfig(AppConfigSchema, {
defaults: { timeout: 5000 },
file: fileConfig,
env: { timeout: parseInt(process.env.TIMEOUT!) },
flags: { timeout: cliArgs.timeout },
});- CLI flags
- Environment variables
- Config file
- Defaults
- Architecture — How packages fit together
- Getting Started — Hands-on tutorials
- Migration — Upgrading and adoption