description: How AgentSync works: the encrypted vault format, push and copy pipelines, and fail-closed reconciliation.¶
Architecture¶
How AgentSync moves bytes from your machine to a Git remote and back, encrypted end-to-end, with reconciliation that fails closed.
What this page owns¶
This page owns the system model, the push and copy pipelines, the vault layout, the security boundaries, and the reconciliation rule. Command flags live in Commands; day-2 operational concerns live in Operations.
System context¶
The diagram shows the major actors and the trust boundaries between them. Plaintext never crosses the network. The Git remote only ever sees ciphertext blobs and metadata.
flowchart LR
AgentsA["AI agents<br/>Machine A"]:::local
CliA["agentsync CLI"]:::local
KeyA["age keypair<br/>Machine A"]:::key
SanEncA["Sanitise<br/>and encrypt"]:::gate
VaultRemote[("Git remote<br/>vault")]:::remote
SanEncB["Decrypt<br/>and apply"]:::gate
KeyB["age keypair<br/>Machine B"]:::key
CliB["agentsync CLI"]:::local
AgentsB["AI agents<br/>Machine B"]:::local
AgentsA -->|local read| CliA
CliA --> SanEncA
KeyA -.recipient.-> SanEncA
SanEncA -->|ciphertext| VaultRemote
VaultRemote -->|ciphertext| SanEncB
KeyB -.identity.-> SanEncB
SanEncB --> CliB
CliB -->|local write| AgentsB
classDef local fill:#2c3e50,color:#ffffff,stroke:#1a252f
classDef key fill:#7d3c98,color:#ffffff,stroke:#4a235a
classDef gate fill:#c0392b,color:#ffffff,stroke:#7b241c
classDef remote fill:#1e8449,color:#ffffff,stroke:#196f3d
The three things to internalise:
- The CLI is the only component that ever holds plaintext.
- The age keypair lives only on its own machine. Adding a machine means adding its public key to the recipient list, not sharing private material.
- The Git remote is fully replaceable. Any host that can serve a Git repository works. The remote learns nothing about the configuration it stores.
The push pipeline¶
When you run agentsync push, bytes flow through four gates in order. Any gate can abort the entire push. There is no partial state in the vault.
flowchart LR
Walk["Walk agent paths"]:::step
Sanitize["Sanitiser<br/>never-sync and literal secrets"]:::gate
Encrypt["Encryptor<br/>age recipients"]:::step
Reconcile["Reconciliation<br/>fast-forward only"]:::gate
Remote[("Git remote<br/>vault")]:::remote
Abort["push fails<br/>guidance printed"]:::abort
Walk --> Sanitize
Sanitize -->|abort on hit| Abort
Sanitize --> Encrypt
Encrypt --> Reconcile
Reconcile -->|diverged| Abort
Reconcile --> Remote
classDef step fill:#2c3e50,color:#ffffff,stroke:#1a252f
classDef gate fill:#c0392b,color:#ffffff,stroke:#7b241c
classDef remote fill:#1e8449,color:#ffffff,stroke:#196f3d
classDef abort fill:#d35400,color:#ffffff,stroke:#a04000
Gate-by-gate:
- Walk: the per-agent path resolver enumerates every artefact the agent owns on disk. Hidden entries, symlinked roots, and dot-prefixed names are filtered before any content is read.
- Sanitiser: the single source of truth for never-sync paths and literal-secret detection. A hit is a hard stop. The push prints which file and which rule, and the vault is never touched.
- Encryptor: every artefact is encrypted to the current recipient set using age. Plaintext exists only in process memory and is never serialised to disk after this point.
- Reconciliation: fast-forward only. If the local vault has diverged from the remote branch, the push stops and prints a recovery path. AgentSync never merges silently.
The copy pipeline¶
There is no automatic down-sync. push only ever writes this machine's own machines/<name>/ namespace; the only way to bring vault content onto local disk is an explicit agentsync copy <machine> <path>. You name the source machine and the artefact (or a directory prefix), and AgentSync decrypts just that and applies it to local disk through the same per-agent apply plan a snapshot uses.
Copy is additive by construction. It can add files and update existing ones; it never deletes a local file the vault does not contain. Skill removal is explicit (agentsync skill remove) precisely so a misconfigured copy on a fresh machine cannot wipe local work. Copy writes only local disk — it never touches the vault, so the next push captures the result normally.
flowchart LR
Fetch["git fetch<br/>vault"]:::step
FF["Fast-forward<br/>or abort"]:::gate
Pick["Resolve machine<br/>+ artefact path"]:::step
Decrypt["Decryptor<br/>age identity"]:::step
Apply["Apply additively<br/>to local disk"]:::step
Local[("Local agent<br/>configs")]:::remote
Abort["copy fails<br/>guidance printed"]:::abort
Fetch --> FF
FF -->|diverged| Abort
FF --> Pick
Pick --> Decrypt
Decrypt --> Apply
Apply --> Local
classDef step fill:#2c3e50,color:#ffffff,stroke:#1a252f
classDef gate fill:#c0392b,color:#ffffff,stroke:#7b241c
classDef remote fill:#1e8449,color:#ffffff,stroke:#196f3d
classDef abort fill:#d35400,color:#ffffff,stroke:#a04000
status and doctor ride on top of the same primitives but never write. status compares a fresh local snapshot to the decrypted vault state. doctor checks key presence, config validity, remote reachability, and vault hygiene.
Reconciliation rule¶
Reconciliation is fast-forward only and is the same rule used by init, push, copy, key add, and key rotate. The contract:
- If the local branch is identical to the remote branch, the operation continues.
- If the local branch is behind the remote, the operation fast-forwards the local before continuing.
- If the local branch is ahead of the remote,
pushfast-forwards the remote;copycontinues reading without writing the remote (copy never pushes). - If the local branch has diverged from the remote, the operation stops with a printed recovery path. AgentSync never merges or rebases automatically.
The reasoning: a configuration vault is a flat record of intent. Three-way merge on encrypted blobs would either produce nonsense or require trust in a merge driver that has no way to inspect the plaintext. Failing closed forces the human to decide which branch is canonical.
See Recover from divergence for the runbook.
Vault layout¶
The vault is a regular Git repository. Inside it, every artefact is suffixed with .age and is age-encrypted to the recipient set defined in agentsync.toml. As of vault format v2 the layout is namespaced per machine, then per agent: each machine backs up into its own machines/<name>/ directory so one machine never overwrites another's. The vault-global agentsync.toml stays at the root.
<vault-root>/
├── agentsync.toml # version, recipient list, branch, remote, sync options
└── machines/
└── <name>/ # one directory per machine (its own backup namespace)
├── claude/
│ ├── settings.hooks.json.age
│ ├── claude.json.age
│ ├── plugins.manifest.json.age # only when claudePlugins.syncPlugins = true
│ ├── commands/
│ │ └── <name>.md.age
│ └── skills/
│ └── <name>.tar.age # tar bundle per skill
├── codex/
│ ├── AGENTS.md.age
│ └── skills/<name>.tar.age
├── cursor/
│ ├── settings.json.age
│ ├── rules/<name>.mdc.age
│ └── skills/<name>.tar.age
└── copilot/
├── instructions.md.age
└── skills/<name>.tar.age
Skill bundles are tar archives so directory-shaped assets round-trip cleanly.
Claude plugins are not stored as an encrypted tree. The marketplace is the source of truth, so push distils ~/.claude/plugins/installed_plugins.json + known_marketplaces.json into a single plugins.manifest.json.age recording each plugin's name@marketplace, scope, and enabled flag (machine-specific absolute paths are dropped). agentsync plugin install <machine> reinstalls from that manifest by shelling out to the claude CLI. The manifest is never applied on pull — a copy sweep skips it — so reinstall is always an explicit step. It is only emitted when claudePlugins.syncPlugins = true, because the manifest can reference third-party marketplaces. Tradeoff: reinstall fetches the latest version (no pin), and local edits to plugin files are not preserved.
Skills and plugins¶
Skills follow the same walker contract on every agent (Claude plugins are manifest-only — see above):
- A missing or symlinked root is skipped silently (it is a legitimate "this agent has no skills directory" signal, not a failure).
- Dot-prefixed names are skipped silently (hidden directories belong to other tools).
- A name that fails validation (containing
.., separators, control characters, a leading dash, or the reserved./..) is rejected with a printed error. Validation guards every place a name becomes a filesystem path or a CLI argument.
Claude plugins are not walked as a file tree. They are represented solely by plugins.manifest.json.age (emitted when claudePlugins.syncPlugins = true); no plugin asset tree is emitted on push or applied on pull.
Once admitted, each artefact is sanitised through the relevant rule set, encrypted, and emitted to its vault path. Sanitiser warnings about redacted secrets are surfaced in the push output so the user knows their literal credential was rejected rather than silently scrubbed.
Security boundaries¶
Three places own the security contract:
- Encryptor: the only path that generates age identities, derives recipients, and encrypts content. Plaintext never leaves this layer for any artefact going to disk or to the network.
- Sanitiser: the only place that decides what is safe to encrypt. Never-sync paths are hard-coded rules. Literal-secret detection is a known-credential-format guard (vendor key prefixes, AWS/GitHub/GitLab/Slack/Google tokens, age identities, PEM private keys, and JWTs in strict mode) — not a general secret scanner: a plain password or bespoke token with no recognised shape is not caught, so encryption, not the scan, is the real protection. The scan's job is to keep well-known credentials out of git history. Its breadth and the base64 redactor are tuned through the
[security]config, resolved bysecurityToPolicy; the never-sync rules are not configurable. A catastrophic tier (the vault's own age key and PEM private keys) blocks the push in every mode and cannot be allow-listed.secretScan = redactswaps an ordinary token in structured config for a$AGENTSYNC_REDACTED_<FIELD>placeholder and pushes; on apply,mergePreservingSecretskeeps a real local value rather than overwrite it with the placeholder. - Tar bundler: exists because some agent assets are directory-shaped. The tar is built in memory before encryption so an intermediate plaintext archive never lands on disk.
Private keys stay on disk in the local runtime directory (~/.config/agentsync/key.txt by default on Unix, with restrictive permissions). They are never committed and never logged.
Path resolution¶
Every agent path is resolved through a single resolver that maps <agent>.<dir> to an absolute path on the current OS. Tests and platform overrides drive the resolver through environment variables rather than rewriting paths inline. The consequence: AgentSync runs identically inside the Docker E2E harness, on a developer laptop, and in CI, without command-implementation sites needing to branch on platform — platform-specific decisions live in src/config/paths.ts and nowhere else.
Vault format versioning¶
The vault carries an integer version field in agentsync.toml. Format v2 sets version = 2 and is the per-machine layout (machines/<name>/…). The field is the old-binary hard block: a v1 binary's schema expected a string, so it cannot load a v2 vault and write flat dirs beside machines/. Loading is two-phase — peekVaultVersion reads the raw version before the schema runs, so a legacy v1 vault (string or absent version) is routed to agentsync vault upgrade instead of failing with an opaque error, and an integer above the current version tells the user to upgrade agentsync itself. agentsync vault upgrade performs the one-time v1→v2 relocation (distinct from the cross-agent translators under src/migrate/, documented in Migrate).
Source map¶
If you are reading the code, this is the rough mapping from concept to module. Keep this list short and link by responsibility rather than file path so renames do not silently invalidate it.
| Concept | Owning module |
|---|---|
| Encryption, identity, recipient derivation | src/core/encryptor.ts |
| Sanitiser rules and literal-secret detection | src/core/sanitizer.ts |
| Tar bundling for directory-shaped artefacts | src/core/tar.ts |
| Fast-forward reconciliation rule | src/core/git.ts |
| Per-agent snapshot and apply | src/agents/<agent>/ |
| Path resolution | src/config/paths.ts |
Config schema (agentsync.toml) |
src/config/schema.ts |
| Vault format migrations | src/migrate/ |
Interactive TUI (bare agentsync) |
src/commands/tui/ |
Compiled-binary packaging¶
The TUI uses OpenTUI, whose
TypeScript wrapper around a native Zig core is loaded through bun:ffi at
runtime. Three packaging consequences follow:
bun run build(the compiled binary atdist/agentsync) runs throughscripts/build.ts, which first executesbun install --os="*" --cpu="*" @opentui/core@<v>so every platform's optional native dependency is resolved intonode_modulesbeforebun build --compileembeds the matching one into bunfs.bun run build:package(the npm-published bundle atdist/cli.js) externalises@opentui/coreso the single-file bundle stays a single file. npm consumers receive@opentui/coreas a runtime dependency declared inpackage.json.- Local development (
bun run src/cli.ts) needs neither step — Bun resolves the native lib for the host platform on first import.