Imported from outfitter-dev/trails (
plugin/skills/trails/SKILL.md). Install upstream withnpx skills add outfitter-dev/trails --skill trails. Copyright stays with the author.
Trails
Contract-first TypeScript framework. Define a trail once with typed input, Result output, examples, meta, and an implementation that establishes how it runs — then surface it on CLI, MCP, or HTTP today, with WebSocket planned on the same contract-first model.
Quick Start
// 1. Define a trail
const greet = trail('greet', {
input: z.object({ name: z.string().describe('Who to greet') }),
output: z.object({ message: z.string() }),
intent: 'read',
examples: [{ name: 'Basic', input: { name: 'World' }, expected: { message: 'Hello, World!' } }],
implementation: (input) => Result.ok({ message: `Hello, ${input.name}!` }),
});
// 2. Collect into topo
const graph = topo('myapp', greetModule);
// 3. Open surfaces
await surface(graph); // CLI — from @ontrails/commander
// await surface(graph); // MCP — from @ontrails/mcp
// await surface(graph, { port: 3000 }); // HTTP — from @ontrails/hono or @ontrails/http/bun
// 4. Headless execution (no surface needed)
const result = await run(graph, 'greet', { name: 'Alice' });
// 5. Test
testAll(graph); // Examples + contract suite in one line
Lexicon
Use these terms — they are non-negotiable in Trails codebases.
| Term | Meaning | Not this |
|---|---|---|
trail |
Unit of work (atomic or composite) | handler, action |
compose |
Composition declaration and runtime verb | workflow, route |
topo |
Queryable graph of trails, signals, resources, and relationships | registry, collection |
implementation |
Authored behavior that establishes how a trail runs from validated input to Result | handler, impl |
surface |
The boundary-owned one-liner that opens a graph | serve, mount |
graph |
Local name for a topo instance | app, registry |
derive |
Produce canonical facts or an intermediate representation from authored truth | operation |
render |
Present derived facts through a surface or format | operation |
meta |
Trail annotations and ownership data | tags, metadata |
warden |
Governance enforcement | linter |
Package Orientation
Current public packages are lockstep at the same Trails framework version.
- Core model:
@ontrails/coreowns Result, errors, trail/signal/entity/topo contracts, resources, layers, execution, validation, and adapter ports. - Surfaces:
@ontrails/commander,@ontrails/mcp,@ontrails/hono, and@ontrails/http/bunopen the same topo on CLI, MCP, Hono HTTP, or Bun-native HTTP.@ontrails/httpowns shared route derivation, OpenAPI, and the Web Fetch kernel;@ontrails/cliowns the framework-agnostic CLI command model;@ontrails/viteadapts Trails surfaces for Vite projects. - Infrastructure:
@ontrails/config,@ontrails/permits,@ontrails/store, and@ontrails/drizzlecover config, authorization, schema-derived stores, and Drizzle SQLite bindings. - Observability:
@ontrails/observabilitydefines sink contracts; its/devand/otelsubpaths provide developer-state and OTel support, while/logtapeand/pinoare temporary sink adapters. - Ecosystem:
@ontrails/testingprovides contract tests and surface harnesses;@ontrails/topographyowns TopoGraphs, semantic diffing, lock manifests, topo-store persistence, and Wayfind graph-read query APIs;@ontrails/wardenowns governance rules. - Stable install policy: Normal 0.x releases use the npm
latesttag. Use the exact package pins in Getting Started. The 0.x line permits documented API changes in minor releases; patches preserve compatibility. Existing 1.0 beta ranges require an explicit manifest migration.
Agent Wayfinding
When saved Topography artifacts can answer a graph question, use Wayfinder before raw text search:
trails wayfind --overview --root-dir . --json
trails wayfind --trails --intent read --root-dir . --json
trails wayfind <trail-id> --contract --root-dir . --json
trails wayfind <trail-id> --deps --root-dir . --json
trails wayfind <trail-id> --impact --map --root-dir . --json
trails schema wayfind
- Start with
trails wayfind --overviewto learn artifact source, freshness, and graph counts. - Use the selected operator CLI shape for filtered discovery:
trails wayfind --trails,--resources,--signals,--surfaces,--trailheads,--entities,--errors, or--adapter <package>. Attach bounded related facts with--include adapters,errors,examples,surfaces, orversions. Topography may expose graph-query APIs beyond the operator CLI/MCP selection; checktrails schema wayfindbefore constructing shell calls. - Use
trails schema <command...>when you need accepted CLI routes, aliases, flags, and schemas before constructing shell calls. - Use
wayfind.describefor a full saved entity record andwayfind.contractfor a trail or version input/output/intent summary. - Use
wayfind.nearby,wayfind.impact, andwayfind.difffor relation context, blast-radius reads, and explicit saved-baseline comparison. - Use
trails wayfind query "<phrase>"for indexed text queries. Treat Wayfinder as graph-read only; do not assume semantic search, signposts, or implications exist in v0.
Wayfinder trails are internal by default. Host apps expose selected queries deliberately, usually as read-only operator tools or MCP resources protected by the host's authorization boundary. Fall back to rg, qmd, source reads, or a fresh compile when Wayfinder reports missing or stale artifacts, when the task needs source code that Topography does not derive, or when writing artifacts is outside your current authority.
Creating Trails
Atomic vs Composite Trails
- Atomic trail: does one thing.
(input, ctx) => Result. Default choice. - Composite trail: composes other trails. Declares
composes: [...], usesctx.compose(). - Runnable trail: an authored contract with an implementation. The runtime runs trails, not implementations.
Trail ID Conventions
Dotted, lowercase, verb-last: entity.show, math.add, search. Dots become CLI subcommands and MCP tool name segments.
Input Schema
Every field gets .describe() — this becomes --help text, MCP descriptions, and form labels.
input: z.object({
name: z.string().describe('Entity name to look up'),
limit: z.number().default(20).describe('Maximum results'),
})
Output Schema
Required for MCP and HTTP surfaces. Define what Result.ok returns.
Intent and Flags
| Field | Effect |
|---|---|
intent: 'read' |
Safe, no side effects. MCP: readOnlyHint. |
intent: 'destroy' |
Irreversible. CLI: auto-adds --dry-run. MCP: destructiveHint. |
idempotent: true |
Safe to retry. |
Examples
Each example is both documentation AND a test case:
- Full match:
expected: { ... }— deep equals - Schema-only: no expected — validates against output schema
- Error match:
error: 'NotFoundError'— asserts error type
See contract-patterns.md for detailed patterns. Copy from trail.md or composition.md.
Surfaces
Adding a surface is a surface() call, not an architecture change. The framework derives everything from the trail contract.
CLI: Flags from Zod, subcommands from dotted IDs, exit codes from error taxonomy.
import { surface } from '@ontrails/commander';
await surface(graph);
Use cli on a trail only for canonical command overrides or trail-owned aliases that still normalize into the same trail contract. String aliases are sibling leaf aliases (find beside search); string-array aliases are absolute command paths (['wf', 'search']). Author app-owned compatibility routes in an exported trailsOverlays = [surfaceOverlay({ cli: { 'wf.search': 'wayfind.search' } })] in the app module, then pass trailsOverlays to the CLI surface's overlays option. Compile, validate, Wayfinder, and trails schema then inspect the same routes the runtime CLI accepts. See CLI surface.
Treat aliases, future input mappings, and trailheads as surface accommodations: render-level fit adjustments, not alternate behavior. The trail stays the capability. A surface entry is the invocable affordance on a surface; an approach is the way a caller reaches it. Aliases add alternate approaches to the same trail, input mappings normalize surface-shaped input into the same trail input, and trailheads group several trails into one entry while preserving the selected trail ID. Use the ADR-0050 test: if the fit would change intent, permits, errors, outputs, lifecycle, side effects, or hide which trail is running, call it a trail fork and author a distinct or composing trail instead.
Classify surface-fit work before editing:
| Shape | Classification |
|---|---|
| One trail, another path, no input reshape | Alias |
| One trail, surface-shaped input that normalizes honestly | Input mapping |
| Many trails, one grouped entry, member trail identity preserved | Trailhead |
| Different intent, permits, errors, outputs, lifecycle, side effects, or hidden member identity | Distinct trail or composing trail |
MCP: Tool names from trail IDs, JSON Schema from Zod, annotations from intent, idempotency, and description.
import { surface } from '@ontrails/mcp';
await surface(graph);
Dense MCP surfaces may use trailheads to group related trails into fewer agent-facing tools. A trailhead is surface rendering configuration, not a core Facet primitive and not a new domain operation. It groups and selects without merging. Author it in MCP surface options, call it with { trail, input }, and expect successful results as { trail, output } so the underlying trail stays visible.
await surface(graph, {
trailheads: {
governance: {
description: 'Run project diagnostics and Warden guidance.',
mcp: { loading: 'deferred' },
trails: ['doctor', 'warden', 'warden.guide'],
},
},
mcpResources: { examples: true, surfaceMap: true },
});
Use trails://surface-map and per-trail MCP resources for cold context before guessing at grouped affordances. Adapter-kit may validate resolved derived evidence for future surface adapters, but it does not define or author trailheads. Do not invent facet(), overlapsWith, or adapter-kit facet config.
HTTP: Routes from trail IDs (dots become path segments), verbs from intent, error responses from taxonomy. Use Hono for framework portability or Bun-native HTTP when you want Bun serving without a third-party runtime; both share the @ontrails/http route/fetch kernel.
import { surface } from '@ontrails/hono';
await surface(graph, { port: 3000 });
import { surface } from '@ontrails/http/bun';
await surface(graph, { port: 3000 });
WebSocket is planned, not shipped. See the CLI surface docs, the MCP surface docs, and the HTTP package docs for derivation details.
Resources
Resources declare infrastructure dependencies — databases, API clients, caches — as first-class primitives alongside trails and signals.
Define a resource with resource():
const db = resource('db.main', {
create: (resourceCtx) => Result.ok(openDatabase(resourceCtx.env?.DATABASE_URL)),
dispose: (conn) => conn.close(),
health: (conn) => conn.ping(),
mock: () => createInMemoryDb(),
});
The create factory receives ResourceContext (env, cwd, workspaceRoot, and validated config when the resource declares a config schema — not the full TrailContext). Resources are singletons, resolved once per process and cached.
Declare on trails with resources: [...]:
const search = trail('search', {
resources: [db],
input: z.object({ query: z.string() }),
output: z.array(z.object({ id: z.string(), title: z.string() })),
implementation: async (input, ctx) => {
const conn = db.from(ctx);
return Result.ok(await conn.search(input.query));
},
});
Access via db.from(ctx) (typed, preferred) or ctx.resource<Database>('db.main') (dynamic escape hatch).
Test with zero config — resources with mock factories auto-resolve in testAll(graph). Mark live-only dependencies with unmockable: { reason } and provide explicit overrides for examples or contracts that need them.
testAll(graph, () => ({ resources: { 'db.main': createSpecialTestDb() } }));
Governance: The warden enforces resource-declarations (usage matches declarations) and resource-exists (resource IDs resolve in the topo).
See contract-patterns.md for declaration patterns and testing-patterns.md for mock strategies.
Testing
testAll(graph) runs the full contract suite in one line:
- Topo validation (composes, schemas, signals, resources)
- Example execution (every example as an assertion)
- Contract checks (output matches schema)
- Detour verification (targets exist)
TDD workflow: Define trail with examples → run tests (red) → implement (green) → refactor.
Edge cases go in testTrail(trail, scenarios). Use createComposeContext() to mock ctx.compose for composite trail unit tests. Surface integration uses @ontrails/testing/cli, @ontrails/testing/mcp, @ontrails/testing/http, and @ontrails/testing/surface-parity.
See testing-patterns.md for the full testing API.
Error Taxonomy
17 fixed-category error classes across 10 categories, plus the dynamic RetryExhaustedError wrapper, with deterministic mapping to exit codes, HTTP status, and JSON-RPC codes:
| Category | Classes | Exit | HTTP | Retry |
|---|---|---|---|---|
| validation | ValidationError, AmbiguousError | 1 | 400 | No |
| not_found | NotFoundError, VersionNotSupportedError | 2 | 404 | No |
| conflict | AlreadyExistsError, ConflictError | 3 | 409 | No |
| permission | PermissionError, PermitError | 4 | 403 | No |
| timeout | TimeoutError | 5 | 504 | Yes |
| rate_limit | RateLimitError | 6 | 429 | Yes |
| network | NetworkError | 7 | 502 | Yes |
| internal | InternalError, DerivationError, RecoverableCompletionError, AssertionError | 8 | 500 | No |
| auth | AuthError | 9 | 401 | No |
| cancelled | CancelledError | 130 | 499 | No |
RetryExhaustedError is dynamic: it wraps another TrailsError, inherits the wrapped error's category for surface mappings, and always reports retryable: false.
Use the most specific class. Return Result.err(new XError(...)), never throw.
See error-taxonomy.md for constructor signatures and patterns. See common-pitfalls.md for anti-patterns.
Migration
Converting existing code to Trails:
- Inventory handlers (routes, CLI commands, MCP tools)
- Extract Zod input/output schemas
- Convert implementations to return Result (replace throw/console.log/process.exit)
- Compose into topo, open surfaces
- Add examples, run
testAll() - Run warden for governance
See migration-checklist.md for the detailed checklist.
Governance
The warden enforces conventions and detects drift:
trails warden # Convention checks
trails warden --lock cached --no-lock-mutation # Governance against cached lock data
trails compile # Regenerate a standalone or current-app trails.lock
trails compile --app my-app # Regenerate one configured app lock from a workspace root
trails validate # Verify the current app, or every app from a workspace root
trails validate --app my-app # Verify one configured app from a workspace root
Each lock-owning app has its own root trails.lock. A configured workspace derives its app set from workspace.apps; it never owns an aggregate workspace-root lock.
For the current generated rule index, read warden-guide.md instead of relying on copied rule prose.
References
| Reference | Content |
|---|---|
| getting-started.md | Full install-to-test walkthrough |
| architecture.md | Hexagonal model, package boundaries, data flow |
| contract-patterns.md | ID naming, schema design, example authoring |
| CLI surface docs | Flag derivation, output modes, exit codes |
| MCP surface docs | Tool naming, annotations, progress |
| http-surface.md | Route derivation, OpenAPI, Hono, Bun-native HTTP, fetch kernel |
| testing-patterns.md | testAll, testTrail, harnesses |
| error-taxonomy.md | Error classes and signatures |
| warden-guide.md | Generated Warden rule guidance from the live manifest |
| common-pitfalls.md | 12 anti-patterns with fixes |
| migration-checklist.md | Step-by-step conversion guide |
| trail.md | Annotated trail skeleton |
| composition.md | Annotated composite trail skeleton |
| patterns.md | Before/after: common transformation patterns |
| express-handler.md | Before/after: Express routes → trails |
| cli-command.md | Before/after: Commander commands → trails |
| mcp-tool.md | Before/after: MCP tool handlers → trails |
| composition.md | Before/after: direct calls -> ctx.compose |