Skip to content

State persistence

Synced from outfitter/docs/documentation/state.md. The repository is the source of truth.

Outfitter launches agent CLIs from a temporary baked composition. During a run, Pi, Claude Code, or another adapter may write state such as settings, sessions, plugin installs, caches, auth metadata, or MCP configuration.

Outfitter does not silently copy every file back into your .agents tree. Instead, each adapter declares the state paths it understands, chooses safe defaults, and lets settings override how writes to those paths are handled.

Most users do not need to configure state_persistence at all. By default, Outfitter keeps known agent CLI state durable and reports unexpected writes.

# This is the behavior most users get without writing any state_persistence block.
# Known Pi and Claude Code state paths default to symlink, so normal setup survives.
# Unknown writes default to warn, so surprising files are reported instead of silently persisted.
state_persistence:
auth.json: symlink # Pi login/auth state survives future runs.
settings.json: symlink # Native CLI settings stay durable.
mcp.json: symlink # MCP/server configuration stays durable.
plugins/: symlink # Installed plugins can be reused.
cache/: symlink # Useful package/cache state can be reused.
sessions/: symlink # Session/project state is durable unless overridden.
unknown: warn # Unexpected writes are visible and not silently persisted.

Some generated runtime files, such as transformed settings or keybindings, may be treated as one-run generated files even though the underlying state path normally defaults to symlink. This keeps Outfitter-managed launch reconciliation from becoming accidental user state.

Outfitter separates runtime files into three groups:

  1. Generated composition files — files Outfitter bakes from the resolved .agents layers and adapter rules. These are temporary and reproducible.
  2. Declared state paths — files or directories the selected agent CLI is expected to read or write, such as settings.json, mcp.json, plugins/, or sessions/.
  3. Unknown writes — anything the agent writes outside declared state paths. Outfitter never silently persists these because it does not know their owner or merge rules.

Only declared state paths can be persisted automatically. Baked artifacts and dumps never contain persisted state — state is runtime, not configuration.

Set state_persistence in settings — globally, per project, or in settings.local.yml for one machine:

# .agents/settings.yml — a stricter policy for a CI project
state_persistence:
settings.json: error # Fail if the agent changes settings during the run.
mcp.json: error # Fail if tool/server config changes during the run.
plugins/: error # Fail if plugin state changes during the run.
unknown: error # Fail if the agent writes an undeclared file.

state_persistence values can be:

state_persistence:
auth.json: symlink # Persist writes through a durable native CLI path.
cache/: discard # Allow writes, then throw them away when the run ends.
plugins/: warn # Allow writes, discard them, and report them after the run.
settings.json: error # Allow the run, then fail if this path changed.
mcp.json: prompt # Ask after the run: persist, discard, or always persist.

Use symlink for state you want to keep, such as login state, durable settings, MCP config, or plugin installs. Use discard, warn, or error for state that should not become durable. Use prompt when you want to decide interactively after each run.

When a prompt path changed during a run and both stdin and stdout are interactive terminals, Outfitter asks what to do with the change after the agent exits:

  • persist — copy the change to the path’s durable destination for this run only.
  • discard — throw the change away with the rest of the baked composition.
  • always — persist the change and record a state_persistence: <path>: symlink override in the editable settings scope, so future runs persist writes to that path automatically. Outfitter never mutates a synced catalog cache: if the active configuration comes from a remote source, the change is persisted once and a warning explains that the choice could not be recorded.

In non-interactive sessions (CI, scripts, piped stdio), prompt falls back to warn and Outfitter prints an explicit prompt skipped: non-interactive notice.

Undeclared writes governed by unknown: prompt cannot be persisted because they have no durable destination; Outfitter reports them as warnings and says so.

Baked composition directories are created under the system temporary directory and removed automatically when the Outfitter process exits or receives a handled signal. Removal deletes symlink entries without following them, so the durable auth/settings state the links point at is never touched. Pass --retain-projection to keep the directory for inspection; Outfitter prints its path.

Each startup also best-effort sweeps outfitter-* directories older than seven days from the temporary root. The sweep never follows symlinks, so a stale directory’s links are removed while their targets survive.

# Story: A developer connects Pi to a model provider during first-run setup.
# Goal: The next `outfitter` launch remembers the login instead of asking again.
state_persistence:
auth.json: symlink # Keep provider login/auth metadata durable.
models.json: symlink # Keep discovered/configured model metadata durable.
# Story: A team publishes a shared .agents catalog.
# Goal: MCP config can come from the catalog, but one user's random runtime files
# should not become shared team state.
state_persistence:
mcp.json: symlink # Keep intentional tool/server config durable.
unknown: warn # Report unexpected writes instead of silently sharing them.
# Story: A platform engineer runs a baked Outfitter task in CI.
# Goal: CI should prove the composition is complete, not depend on hidden runtime mutation.
state_persistence:
settings.json: error # Settings drift means the composition is incomplete.
mcp.json: error # Tool config drift should fail the job.
plugins/: error # Plugin installs/updates should be explicit in the tree.
unknown: error # Any undeclared write is a reproducibility problem.
# Story: A consultant switches between client repositories.
# Goal: Sessions, caches, and temp files from one client should not show up in another.
state_persistence:
sessions/: discard # Throw away conversation/session state after the run.
cache/: discard # Throw away cache data tied to this run.
tmp/: discard # Throw away temporary runtime artifacts.
unknown: warn # Still report surprising writes for investigation.
# Story: An engineer tries new plugins or package installs locally.
# Goal: Let the experiment run, but report what changed so the user can decide
# whether to make it durable later.
state_persistence:
plugins/: warn # Allow plugin changes, but do not persist silently.
unknown: warn # Surface other writes that may need a policy.

The Pi adapter declares these paths:

state_persistence:
auth.json: symlink # Login/auth state; allowed: symlink, error, prompt.
settings.json: symlink # Pi settings; generated launch transforms may be one-run.
keybindings.json: symlink # Pi keybindings; Outfitter may generate launch keybindings.
mcp.json: symlink # MCP/server configuration.
models.json: symlink # Model/provider metadata.
trust.json: symlink # Pi trust decisions.
plugins/: symlink # Pi plugins.
cache/: symlink # Pi cache data.
sessions/: symlink # Pi sessions; the "Pi sessions" section covers how this works today.
npm/: symlink # Pi npm package installs.
git/: symlink # Pi git package checkouts.
tmp/: symlink # Pi temporary runtime tree; allowed: symlink, discard.
utilities/: symlink # Shared utility binaries such as rg/fd.
bin/: symlink # Utility binary links.
unknown: warn # Undeclared writes; allowed: discard, warn, error, prompt.

Pi stores conversation transcripts under its agent directory, and Outfitter points PI_CODING_AGENT_DIR at a baked composition that is deleted when the run ends. So that sessions are not deleted with it, every Pi launch sets PI_CODING_AGENT_SESSION_DIR to Pi’s own durable per-project session folder:

~/.pi/agent/sessions/--<your-project-path>--/

This is the same folder a standalone pi uses in that project, so history is shared both ways and resuming works after the baked composition is gone:

Terminal window
outfitter run # first session
outfitter run -- --continue # picks up where the previous run left off

The default applies to every Pi launch, interactive or not, so outfitter run -p '…' in a script or CI job records a session in the same place. Nothing is copied back after the run: Pi writes straight to the durable directory.

To change or turn off session storage, pass Pi’s native flags through, or set the environment variable yourself — Outfitter never overrides a session directory you have already chosen:

Terminal window
outfitter run -- --no-session # ephemeral: record nothing
outfitter run -- --session-dir ./.pi/sessions # keep this project's sessions in the repo
PI_CODING_AGENT_SESSION_DIR=/workspace/.pi/agent/sessions outfitter run

The last form is how a resident or in-cluster agent keeps continuity across restarts: point the variable at a persistent volume.

By default a Claude run inherits the machine’s own configuration: Outfitter sets no CLAUDE_CONFIG_DIR, and the composition reaches the session as a plugin directory instead. Claude reads and writes ~/.claude exactly as it does in a native session, so credentials, workspace trust, permission approvals, and session history need no bridge at all — there is nothing to seed and nothing to copy back, and nothing Outfitter does can race your other Claude sessions. The rest of this section describes an isolated run (--isolated, or isolation: isolated in your ~/.agents/settings.yml), where the projection is the whole configuration.

Under isolation, Claude credentials need a narrow adapter bridge in addition to the path-keyed state below. Claude reads .credentials.json and .claude.json directly from CLAUDE_CONFIG_DIR; the ephemeral projection gives .credentials.json no durable home, and .claude.json’s native location (~/.claude.json, outside ~/.claude) does not share its config-dir-relative path. Outfitter seeds .credentials.json before launch. It seeds oauthAccount and hasCompletedOnboarding when those keys exist in durable state. It also seeds the current working directory’s accepted-trust bit only when that exact trust decision already exists in durable state. Afterward it copies back the whole .credentials.json when changed and atomically merges oauthAccount. If the durable credentials also changed after seeding, Outfitter preserves that concurrent refresh and warns instead of copying the projected credentials back. Claude MCP OAuth tokens live under mcpOAuth in .credentials.json, keyed by <serverName>|<hash>, so server authorizations acquired in an Outfitter-launched Claude session persist across runs through that whole-file copy-back. Outfitter never copies the full machine-local ~/.claude.json into a projection or merges its other projected state back. Trust accepted inside an isolated session is therefore discarded, so an isolated run prompts for trust in a workspace that was never trusted natively — which is one reason isolation is not the default.

Isolated Claude session history has a second narrow bridge because CLAUDE_CONFIG_DIR also redirects Claude’s native projects/ tree into the temporary projection. Before launch, Outfitter derives Claude’s project slug from the absolute working directory and copies only that slug directory from ~/.claude/projects/. This keeps other projects’ transcripts out of the projection while making claude --continue and claude --resume see earlier native or Outfitter-launched sessions. After the run exits or throws, Outfitter merges every new or content-changed regular session file from the projection’s slug directories back into ~/.claude/projects/ atomically with mode 0600. Durable files are never deleted. A seed or copy-back failure emits a warning and does not replace Claude’s exit code or error.

These declared paths describe the isolated strategy; an inherited run writes to the native locations directly and declares nothing.

The Claude Code adapter declares these paths:

state_persistence:
settings.json: symlink # Claude Code settings.
agents/: symlink # Claude agent definitions.
skills/: symlink # Claude skills.
commands/: symlink # Claude commands/prompts.
plugins/: symlink # Claude plugins.
projects/: symlink # Claude project/session state.
debug/: symlink # Claude debug state.
unknown: warn # Undeclared writes; allowed: discard, warn, error, prompt.

When a path uses symlink, the durable destination is the native CLI state location — ~/.pi/agent/... for Pi, ~/.claude/... for Claude Code. The native location is not another configuration layer: it does not participate in resolution or merge precedence; it only provides a durable destination for state paths.

For a ported Claude Code setup, ~/.claude configuration entries are themselves symlinks into ~/.agents/, so persisted configuration state lands in the protocol tree while session and auth state stays native.

For the complete adapter contract and rationale, see State writeback strategy.