Skip to content

Outfitter Architecture

Synced from outfitter/docs/architecture/README.md. The repository is the source of truth.

Outfitter is a TypeScript CLI that resolves, composes, bakes, and launches agent configuration stored in the Dotagents .agents protocol (pinned at revision 502a9d5). It is generic enough for organizations to define resources once and run them across multiple agent CLIs, while supporting pi first and most deeply, plus Claude Code as an additional supported adapter.

Outfitter’s differentiated value is source resolution, selective composition, task baking, validation, provenance, and harness projection — not ownership of a configuration format. The protocol tree is the source of truth and remains useful without Outfitter.

Formal implementation requirements live in ../requirements/; note the transition status — this document describes the RFC #165 target architecture.

  1. TypeScript first: production code, tests, schemas, and tooling are centered on TypeScript.
  2. Protocol-native storage: all authored configuration is a protocol .agents payload. Outfitter-specific metadata (settings, provenance) is optional, namespaced, and removable without losing the underlying resources. No parallel manifest, persona, or task YAML formats.
  3. Pin the protocol: the implementation and its conformance fixtures target one pinned protocol revision. Protocol upgrades are deliberate, reviewed changes — never silent tracking of upstream main.
  4. One resolver: listing, validation, task baking, running, and dumping share a single effective-resolution path. There is no code path that composes resources differently from another.
  5. Deterministic composition: identical sources, refs, selections, and inputs produce identical results — the same effective identity for a run, and byte-identical output for a dump.
  6. Generic composition, adapter projection: compositions are harness-neutral; adapters project them to agent-specific files, flags, and environment variables. If a composition asks for something an adapter cannot project, Outfitter warns to stderr; --strict makes it fatal.
  7. Pi-native by design: pi is not merely the first adapter — targeting pi natively and completely is a deliberate design choice. Pi’s configuration surface (extensions, plugins, subagents, keybindings, bootstrap) is treated as first-class, and the composition model is validated against it before being generalized to other harnesses. Claude Code and Codex CLI are supported additional adapters with documented gaps, not co-equal design targets; Codex currently projects only model and MCP servers.
  8. Command objects for complexity: non-trivial CLI commands are implemented as command objects with explicit dependencies and typed inputs/outputs.
  9. Schema validation at boundaries: every persisted file format read by Outfitter is validated at the read boundary (JSON Schema for JSON and YAML files, frontmatter schemas for markdown resources).
  10. Complete test coverage early: the project maintains a test framework with a 100% global coverage requirement.
  11. Complexity limits early: ESLint is configured with maximum complexity 10.
  • Runtime: Node.js >=24.18.0 <25, tested with the exact version pinned in .node-version.
  • Language: TypeScript. Package manager: npm.
  • CLI framework: Commander ^14 (default commands, aliases, allowUnknownOption, pass-through argument collection, testable parser construction).
  • Test framework: Vitest ^4 with @vitest/coverage-v8; 100% global coverage thresholds.
  • Linting: ESLint ^10, @eslint/js, typescript-eslint, complexity: ["error", 10].
  • Schema and validation: TypeBox for schema authoring, JSON Schema artifacts for persisted contracts, AJV for runtime validation.
  • YAML (yaml) for Outfitter settings; JSON for protocol files (mcp.json, models.json, config.json) per the protocol.
  • Merge behavior: defu for settings; protocol merge-by-ID and JSON merge semantics implemented per the pinned revision with conformance fixtures.
  • Process launch: cross-spawn. Discovery and URI parsing: glob, hosted-git-info. Diagnostics: chalk. Templating of generated harness files: liquidjs with Outfitter-specific delimiters.

The CLI workspace includes code/cli/tsconfig.json for strict typechecking and code/cli/tsconfig.build.json for production emission from code/cli/src/ to code/cli/dist/.

The repository layout and source/test directory boundaries live in ./file_structure.md.

Pi-native first-run onboarding, .agents detection, and the ~/.claude porting flow live in ./onboarding.md.

Outfitter resolves protocol resources from layered .agents trees:

~/.agents/ # global layer
agents.md
system-prompt.md
mcp.json
models.json
agents/<id>/agent.md # + optional config.json
skills/<id>/...
tasks/<id>/task.md
knowledge/...
commands/...
settings.yml # Outfitter settings (namespaced, optional)
settings.local.yml # gitignored machine-local overrides
cache/ # synced remote sources (Outfitter-owned)
<project>/.agents/ # workspace overlay: same shape, minus cache
settings.yml
settings.local.yml

Remote sources (catalog repositories) supply additional layers below the local ones, in configured order. A standalone catalog repository’s root is the payload; a colocated source nests it under .agents/ (selected with the source’s path:).

  • Resources merge by ID across layers: workspace over global over remote sources in configured order. The winning definition replaces lower ones; there is no partial merge of markdown resources.
  • After layer resolution, an agent’s inherits graph resolves through that same effective set. Traversal is recursive parent-first, multiple parents retain authored order, diamond ancestors contribute once, and cycles or missing parents fail with their chain.
  • Inherited selections retain the declaring agent and winning layer. This provenance is required for parent-local skills, per-agent configuration, and catalog prompt files to resolve against their owner rather than the selected child.
  • JSON files (mcp.json, models.json, per-agent config.json) follow the protocol’s JSON merge behavior across layers.
  • The resolver produces one immutable effective resource set per invocation: every slug mapped to its winning source, with shadowed definitions retained for diagnostics.
  • list, validate, run, and dump all consume the same effective resource set. This is a hard invariant, enforced by sharing one Resolver component.
  • Conformance fixtures (layer trees + expected effective output) pin the merge semantics to the protocol revision; a protocol upgrade updates fixtures in the same change.

Outfitter settings are the only Outfitter-specific files in a tree: settings.yml plus a flat sibling settings.local.yml at both scopes. There is no nested local settings directory.

Highest to lowest:

  1. <project>/.agents/settings.local.yml
  2. <project>/.agents/settings.yml
  3. ~/.agents/settings.local.yml
  4. ~/.agents/settings.yml
  5. Cached remote settings referenced by remote_settings
  6. Built-in defaults

The internal Settings object is the single conceptual result of reading all settings sources and applying precedence. Every settings file MUST validate against settings.schema.json.

default_agent: engineer # which agent runs by default
default_harness: pi # which harness to launch: pi, claude, or codex
cache_directory: ./cache
sources:
- github: ai-outfitter/.agent
ref: <commit-sha>
- github: example/payments-service
ref: v1.2.0
path: .agents
- uri: git+https://git.example.com/team/agents.git
ref: main
- path: ../shared-agents
remote_settings:
- github: example/.outfitter
ref: <commit-sha>
path: .agents/settings.yml
state_persistence: {}
custom_settings: {}

Rules:

  • Settings do not carry resource selections. An agent’s loadout — skills, subagents, mcp, extensions, plugins, model, thinking, tools — is declared on the agent (agents/<id>/agent.md frontmatter or config.json), not in settings. There is no profiles map and no default_profile.
  • default_agent names the agent plain outfitter runs; default_harness selects the harness (pi, claude, or codex).
  • sources entries MUST specify a local path, a remote uri, or a github shorthand; remote entries accept ref and payload-subdirectory path. Relative path values resolve relative to the settings file containing them.
  • remote_settings entries point at settings-style YAML files inside synced remote repositories; fetched by outfitter sync and loaded at lower precedence.
  • cache_directory selects the repository cache root for sync, remote-settings loading, remote layer discovery, and default-catalog bootstrap. Its default is ~/.agents/cache.
  • custom_settings may contain arbitrary YAML-compatible nested data, deep-merged by precedence.

For a selected agent, the composer builds the run’s effective configuration from the effective resource set:

  1. Identity: the nearest inherited system_prompt declaration (or tree root system-prompt.md) is the base; root agents.md follows; inherited/child append_system_prompt fragments and inherited/child agent bodies append parent-first. Each fragment carries source, owner, layer, and trust provenance.
  2. Prompt trust: { file } reads required trusted catalog content inside the declaring agent’s winning layer. { repo_file } reads optional untrusted active-repository content inside the project root. Both are checked after symlink resolution; named prompt slugs are not part of this contract.
  3. Subagents: agents named in the selected agent’s subagents loadout are marked for projection into the harness’s delegation surface; their own selected skills are resolved and materialized separately from the leader’s active skills.
  4. Skills: skills named in the loadout are materialized (frontmatter references/scripts/assets resolved from their two-root model, validated for escapes and collisions) into generated skill directories.
  5. Harness elements: extensions and plugins named in the loadout are marked for the adapter (first-class for pi).
  6. Structured configuration: models.json, mcp.json, and per-agent config.json merge per protocol JSON semantics; the loadout’s model, thinking, and tools select and constrain within them. A provider/model selection resolves once into a harness-neutral model target (dialect, endpoint, credential-variable reference, headers, capabilities, and provenance); adapters translate that target to native controls without changing its endpoint.

The composed result is a harness-neutral composition plan — the input to dump and adapter projection.

Tasks and bake — freezing a named work contract and its typed inputs into an immutable execution artifact — are the subject of a separate upcoming RFC and are not part of this architecture yet. The ai-outfitter/actions Action currently launches a composed agent headlessly with structured inputs through the same resolver.

outfitter dump writes the effective resource set (or one selection’s transitive closure) as a plain .agents/ directory. The removable .agents/.outfitter/composition.json audit record lists each composed agent’s resolved inheritance chain and ordered prompt provenance without prompt contents or absolute paths.

  • Deterministic: byte-identical for identical inputs.
  • Safe: never contains credentials, auth state, sessions, transcripts, caches, backups, mutable harness state, or symlinks resolving outside the tree. Dump shares the state-path knowledge of the adapters to guarantee exclusion.
  • Protocol-shaped: valid payload for any protocol consumer; Outfitter provenance metadata is optional and removable.

Generated harness files may use LiquidJS templating with Outfitter-specific delimiters ([[= expression ]], [[% tag %]]) so templates do not collide with agent prompt, skill, Handlebars, Mustache, Jinja, or Claude Code syntaxes. Plain [[ -f file ]] shell text passes through unchanged. The template context exposes resolved custom_settings, settings, the composition, the agent id, and the project root. Undefined output variables and unknown filters are template errors; undefined variables in conditions evaluate falsy. Source files are never rewritten; templating applies only to generated composition files.

A launch materializes the composed agent under the system temp directory:

$TMPDIR/outfitter-<agent>-<harness>-<random>/

Adapter-declared state paths symlink to durable native CLI locations (~/.pi/agent/..., ~/.claude/...) per the functional state model — see ./state_writeback_strategy.md. State handling strategies are symlink, discard, warn, error, and prompt; unknown writes are never silently persisted. state_persistence policy lives in settings.

The Pi adapter reconciles generated runtime settings.json and keybindings.json (reserving Shift+Tab for Outfitter mode switching) as non-durable generated files. Interactive Pi launches inject the Outfitter bootstrap extension for build/plan mode switching; non-interactive launches skip it. After projection and bootstrap attachment, Outfitter reads root-owned system extension hooks and prepends their Pi extensions to every launch plan, including non-interactive and RPC launches. This post-projection boundary keeps the hook out of .agents layers, settings, composition, loadout support checks, and --strict handling. It is launcher-scope default injection rather than managed enforcement; the hook source is stamped into the child environment so downstream evidence can identify system-path versus session-provided configuration.

During outfitter run, the Outfitter process remains alive while the child agent CLI runs and owns the temporary composition lifecycle.

Each supported CLI has an AgentAdapter implementation that projects a composition plan to native configuration:

interface AgentAdapter {
readonly id: string;
readonly supportedElements: readonly string[];
readonly statePaths?: Readonly<Record<string, StatePathDeclaration>>;
createProjection(
composition: CompositionPlan,
input: {
readonly rootDirectory: string;
readonly homeDirectory?: string;
readonly cacheDirectory?: string;
readonly settings?: Settings;
readonly projectDirectory?: string;
},
): AgentProjectionPlan;
createLaunchPlan(projection: AgentProjection, passThroughArgs?: readonly string[]): AgentLaunchPlan;
getUnsupportedElements(composition: CompositionPlan): readonly string[];
}

The adapter owns CLI-specific details: env vars, flags, state path declarations, warnings, and unsupported elements.

Pi is the default adapter. Outfitter prefers native pi mechanisms: PI_CODING_AGENT_DIR, PI_CODING_AGENT_SESSION_DIR/--session-dir, --extension, --skill, --prompt-template, --system-prompt/--append-system-prompt, model/provider/thinking flags, and generated settings reconciliation where flags are not the right mechanism. Startup-order-impossible requests surface as adapter warnings (--strict makes them fatal). Because the projection root is removed after the run, launches default PI_CODING_AGENT_SESSION_DIR to pi’s durable per-project session folder (~/.pi/agent/sessions/--<encoded cwd>--) so sessions outlive the projection and stay shared with a standalone pi; an inherited PI_CODING_AGENT_SESSION_DIR is left in place, and pass-through --session-dir/--no-session still win by pi’s own precedence. If generic naming conflicts with pi behavior, prefer pi’s terminology.

Outfitter launches claude over the user’s own Claude configuration by default, passing the projection root through --plugin-dir, and maps the elements supported by the current baseline adapter to native files and flags. An isolated run (--isolated) points CLAUDE_CONFIG_DIR at the projection root instead, making the composition the whole configuration. Persistent state projection and managed harness symlinks are deferred to #187; setup does not create them.

Outfitter launches codex, maps model selection to -m, and injects selected MCP servers through repeated TOML-valued -c mcp_servers.<id>.<key>=... overrides. The overrides merge with Codex user and project configuration because Codex has no way to run with only the projected MCP servers, so every launch warns that lower-layer servers remain active, including when the composition selects no servers. Codex cannot project the composition identity and reports it unsupported on every launch, so --strict aborts every Codex launch. Pass exec through to select Codex’s non-interactive subcommand; without it, the normal interactive CLI launches. Other loadout elements remain unsupported and use the normal warning mechanism.

  • outfitter run [agent] (default command): resolve → compose → project → launch. A positional agent slug selects what to run (default from settings default_agent); --harness <pi|claude|codex> selects the harness (default from settings default_harness, then pi); unknown args pass through to the child CLI; --strict makes warnings fatal. Interactive clean-home launches start Pi-native onboarding; non-interactive clean-home launches require outfitter setup first.
  • outfitter setup [source]: launch the bundled, model-free Pi walkthrough with the original three setup modes (default catalog, create a profile, or another catalog), original profile/target wording, and optional direct-source path; append only the default CLI-agent choice (Pi/Outfitter preselected); then atomically apply the result through the CLI state machine.
  • outfitter sync: validate local settings; atomically fetch configured remote settings; reload the merged settings; then atomically fetch every resulting remote source into <cache_directory>/repos/<encoded-uri-and-ref>/ and record its verified commit under <cache_directory>/source-state/. Validate before swapping, preserve the last good checkout on failure, report per-source status, and redact embedded credentials from all output. Required-source failures exit nonzero.
  • outfitter run: enforce the selected source-cache policy before resolution. Healthy manifest-backed caches stay network-free; repair mode atomically rebuilds unhealthy remote caches, locked mode requires full commit pins, and offline mode fails closed without network access. Explicit local paths are never cache-managed.
  • outfitter sources: report local, direct, remote-settings, and transitive source precedence plus credential-redacted cache health and revisions.
  • outfitter list [kind]: report the effective resource set — slugs, winning sources, shadowed definitions — deterministically.
  • outfitter validate [--strict] [--json]: protocol layout, frontmatter and JSON schemas, unresolved loadout slugs, skill reference escapes/collisions, settings schema.
  • outfitter dump [--agent <id>] [--out]: write the deterministic tree, optionally restricted to one agent’s transitive closure.

outfitter task bake is deferred to the tasks RFC.

Each non-trivial command is a command object with explicit dependencies (SettingsLoader, Resolver, Composer, Baker, ProcessRunner) and typed inputs/outputs — no direct process.argv access inside command logic. Benefits: unit testability, low parser coupling, explicit dependencies, natural complexity limits.

Validation happens at every file boundary: settings (settings.schema.json), protocol JSON files (schemas per the pinned revision), resource frontmatter, task input contracts, command inputs, and cache metadata. Diagnostics point to the file and key that failed where practical.

  • Global coverage threshold: 100% for statements, branches, functions, and lines.
  • Protocol conformance fixtures: layered .agents trees with expected effective resolution output, pinned to the protocol revision.
  • Deterministic tests for settings precedence, task input validation, bake determinism (content-hash stability), dump byte-identity and hygiene (no credentials/state), cache path encoding, generated launch env/argv, and unsupported elements with --strict.
  • Scenario fixtures for common combinations instead of one-off bespoke setup; conventions in ./file_structure.md.
  1. npm with committed package-lock.json; Commander; Vitest with V8 coverage.
  2. Resource IDs are filesystem-safe slugs; display names live in frontmatter.
  3. The protocol revision is pinned; upgrades are explicit, fixture-backed changes.
  4. There is no legacy profile compatibility layer: no readers, writers, aliases, detection, or migration commands. The only old-version material is the manual migration reference routed from the bundled Outfitter skill.
  5. A remote repository named .outfitter is supported only as a distribution convention for the new protocol payload.
  6. Claude Code and Codex CLI are supported as additional adapters; Codex currently projects only model and MCP servers, and pi remains the default.