Skip to content

Client ↔ provisioning synchronization

How artifacts served by the server (skills, agents, hooks, instructions, mcp) reach client providers (claude, opencode, codex, kiro) and stay in sync. Package internal/provisioning; MCP tools sync_check/sync_apply/sync_pull; CLI cartographer status/sync/resolve. Rationale: synchronization/provisioning decisions and client decisions.

The problem

The artifacts a client must have come from two sources that change over time:

Source Where it lives When it changes
Server bundle internal/skillbundle/bundled/ (compiled into the binary) — skill only Cartographer upgrade
KB artifacts skills/, agents/, hooks/, mcp/ at the KB root (git) git pull, skill_install, edits

The bundled skill catalog is cartographer-ops (server and client operations), kb-create, kb-import, and kb-conflict-resolve. Because the bundle is compiled into the binary, clients receive operational guidance that matches their installed Cartographer version after sync.

Without a sync mechanism, the files materialized on the client silently drift on every bundle upgrade or KB git pull. Goal: the client notices the change and realigns, with a model extensible to new kinds without a redesign.

Core concepts

1. Provisioning artifact (provisioning.Artifact)

The generic unit of what gets synchronized: kind (skill/agent/hook/instructions/mcp, extensible), name, source (bundle | kb:<name>), version, content_hash, signed. The per-provider destination is resolved by destDir(kind, name, provider). A new future kind = a new emitter in BuildManifest + a case in destDir: manifest, lockfile, revision, detection, triggers, and pruning don't change.

A KB's provisioning artifacts are edited through two equivalent channels: git (clone/push of the KB repo, as usual) or the MCP tools artifact_read/artifact_write/artifact_list/artifact_delete (→ control-plane.md §KB-root artifacts; requires allow_artifact_write: true for the KB). The MCP write path goes through gitWrap (lock, commit, SyncIn/SyncOut), so it also absorbs concurrent git pushes; the manifest revision changes with the content on disk, and the layered triggers propagate to clients either way. templates/<slug>.md is intentionally a KB-only artifact: it uses the same artifact lifecycle but is excluded from BuildManifest, the kind × provider matrix, lockfiles, pruning and manifest revisions.

2. Provisioning manifest — source of truth

Generated by the server (BuildManifest, which knows the bundle + all mounted KBs): the complete set of what the client should have, with an aggregate revision — the hash of the ordered list of content_hashes (+ kind+name+source). A single changed artifact changes the revision: drift is detected by comparing a single hash.

3. Client lockfile — applied state

.cartographer-sync.lock.json next to the generated configs, in v2 multi-provider format (provisioning.LockFile): one Lock per provider (applied_revision + managed[], the files Cartographer itself created). The old v1 single-provider format is read and automatically migrated by ReadLockFile.

Drift = manifest.revision ≠ lockfile.providers[<provider>].applied_revision. managed[] enables pruning: sync only removes what it created, never user files.

Detection and triggers — layered model

Layer 1 — SessionStart bootstrap hook (primary trigger)

Hook cartographer-bootstrap (reserved name, provisioning.BootstrapHookName): generated entirely by the client (never from the manifest), installed/updated by EnsureBootstrapHook on every connect/sync and removed by disconnect. At the start of a session, the bootstrap.sh script (deterministic, silent, always exit 0) checks that cartographer is in PATH and launches cartographer sync --auto-trust in the background. Explicitly excluded from ComputeDiff/Apply (a server manifest will never contain it → it's never treated as an orphan to prune). Details → D60.

Provider Hook point Registration
claude SessionStart entry in ~/.claude/settings.json
codex [[hooks.SessionStart]] managed block in ~/.codex/config.toml
opencode session.created event generated plugin in ~/.config/opencode/plugins/
kiro — (no native hook) falls back to Layer 2

Layer 2 — On-demand MCP tools

Tool What it does
sync_check Returns manifest.revision, status (in-sync/drift), and the diff against the supplied lockfile. Read-only.
sync_apply Materializes into base_dir shared with the server's filesystem (local/stdio deployment), updates the lockfile, prunes. Honors the signature gate. Supports dry_run.
sync_pull Returns the manifest with file contents in base64: used by the remote HTTP client, which does not share the filesystem with the server. Read-only.

Layer 3 — Push via MCP notifications (stdio only)

On the stdio transport, the server emits notifications/skills/list_changed after skill_install (via notifyWrap, outside gitWrap so it fires after the commit); capability skills.listChanged: true in initialize. Over HTTP, Server.Notify is a no-op: Layers 1-2 still cover this case. Serialized on writeMu, never held during dispatch (verified with -race).

Remote HTTP client (cartographer connect/sync/status)

When client and server don't share a filesystem (internal/client, internal/clientconfig, cmd/cartographer/clientsync.go):

  1. the client calls sync_pull (once per KB configured in .cartographer.yaml) and merges the manifests with provisioning.MergeArtifacts;
  2. it reconstructs each artifact hash from received paths, bytes and executable modes, then verifies any detached signature against the local KB pin;
  3. Apply materializes/prunes and writes the v2 lockfile;
  4. pruning remains managed-only.

Each sync_pull call (and the equivalent cartographer reindex remote call) is qualified with that KB's tool-name prefix (D102), discovered from a live /health snapshot rather than re-derived client-side (D120: resolveKBTargets/qualifyTool in cmd/cartographer/multikb.go). This keeps the client plumbing correct whether the server prefixes tools or not — an unprefixed KB is called unchanged, exactly as before D120.

Security

  • Cryptographic signature gate. A configured KB signer creates a canonical Ed25519 envelope over domain, format version, source KB, kind, name, version and content hash. The remote client recomputes the content hash and verifies against out-of-band signing_keys pins before writing any provider file or lockfile. signed:true is verification output only; malformed, invalid, source-mismatched, unknown-key or tampered content fails the whole sync. Bundled artifacts use the separate built_in:true origin.
  • Explicit unsigned authorization. trust: true and one-shot --auto-trust retain the backwards-compatible approval path for eligible unsigned KB artifacts, but never change signed. Rotate keys by pinning the new public key first, switching the server signer second, then removing the old pin.
  • Path traversal. Artifact names and paths come from the server via JSON: provisioning.Apply rejects anything that is not filepath.IsLocal (no absolute paths, no ../) before writing. Applies to every kind.
  • Sync never carries secrets, only references (skills-services-secrets.md).

Pruning

  • Apply/PruneManaged remove only the paths in managed[] that are no longer in the manifest — never files not created by Cartographer.
  • Empty directories (pruneEmptyDirs): after every removal, it walks up the parent directories deleting the ones left empty, always stopping at known roots (.claude, .codex, .kiro, .opencode, .config, .config/opencode, or BaseDir), which are never removed. os.Remove (never RemoveAll) is the natural guard against non-empty directories.
  • MCP configs reduced to empty (configurator.Remove): once the entry is removed, an empty mcpServers/mcp map is removed too; .kiro/settings/mcp.json and opencode.json reduced to an empty shell are deleted. Absolute exception: .claude.json is never deleted (it's a file shared by Claude Code). For .codex/config.toml, only the marker-delimited block is removed (the file is deleted if it ends up empty).
  • Round-trip connectdisconnect leaves no residue, except for the provisioning roots themselves (deliberate boundaries): test TestRoundTrip_ConnectDisconnect_NessunResiduo. Details → D63.

Idempotence

sync_apply/provisioning.Apply applied twice on the same revision are no-ops; dry_run shows the diff without writing (sync_apply(dry_run=true), --dry-run on the client). The provider JSON config merge remains the non-destructive deep-merge of configurator.mergeJSON.

Kind × provider matrix

Resolved by destDir(kind, name, provider); an unmapped combination is unsupported (≠ needs_approval: no approval would unblock it) and clients filter it out upstream with FilterForProvider — it counts as neither drift nor pending (D50).

Kind claude opencode codex kiro
skill .claude/skills/<name>/ .opencode/skills/<name>/ .codex/skills/<name>/ .kiro/skills/<name>/
agent .claude/agents/<name>.md (verbatim) .opencode/agent/<name>.md (translated) .codex/agents/<name>.toml (translated) unsupported
hook .claude/hooks/<name>/ + registration in settings.json .opencode/hooks/<name>/ + generated JS plugin .codex/hooks/<name>/ + block in config.toml unsupported
instructions managed block in .claude/CLAUDE.md block in .config/opencode/AGENTS.md block in .codex/AGENTS.md file .kiro/steering/cartographer.md
mcp key mcpServers.<name> in .claude.json key mcp.<name> in opencode.json block [mcp_servers.<name>] in config.toml key mcpServers.<name> in .kiro/settings/mcp.json

Agents and hooks

  • KB layout (optional/backward-compatible): agents/<name>.md (Claude subagent: frontmatter + body) and hooks/<name>/ (script + hook.json with event/matcher/command). BuildManifest scans these (KB only, the bundle remains skill-only).
  • ContentHash: agent → sha256 of the file; hook/skill → an aggregate hash of the directory (ContentHashDirOS); always computed on the source, never on the translated form — so the manifest↔lock comparison doesn't depend on the provider.
  • Agent translation (translateAgentForProvider, a pure function): the source is always a Claude subagent. Claude = passthrough; OpenCode = minimal frontmatter description + mode: subagent + verbatim body; Codex = TOML name/description/developer_instructions (verbatim body). Fields that can't be mapped reliably (tools, model) are dropped, not guessed. Details → D55/D58.
  • Hook registration: besides materializing the files, Apply registers the hook in the provider's native mechanism (internal/provisioning/hooksettings.go), idempotently and prunably:
  • claude: merges the entry into hooks.<Event>[] of settings.json (ownership = the .claude/hooks/<name>/ marker in the command: the materialized path, or — for commands that don't reference the hook's dir, e.g. a one-liner jq ... — an inert shell comment # cartographer-hook: ... appended at the end); the file is treated as generic JSON, unknown keys survive (D57);
  • codex: marker-delimited block # cartographer:hook:<name>:begin/end in .codex/config.toml via internal/blocktext — the TOML is never parsed/re-serialized (D58). Codex rewrites the file and drops the markers with every other comment, so before writing the block Apply removes any registration of that hook left outside it — identified by the .codex/hooks/<name>/ path in its command — which would otherwise make the hook fire twice, and reports the repair in AppliedResult.Warnings (D99);
  • opencode: a deterministic JS plugin cartographer-<name>.js in ~/.config/opencode/plugins/, generated only if the event is mappable (openCodeHookEvents); an unmappable event → files are still materialized + a warning in AppliedResult.Warnings (D59);
  • a missing/malformed hook.json skips registration without failing Apply; a command whose first token is a relative path (contains /, e.g. ./notify.sh) is resolved to the materialized absolute path; bare names (e.g. jq) are left verbatim, resolved via PATH.
  • A file's executable bit comes from the KB and is preserved on materialization (including skill scripts); hooks retain an unconditional executable floor for every file other than hook.json, which is always non-executable. The effective mode is part of the versioned artifact hash, so chmod alone changes the revision and realigns existing installs.
  • Per-kind counts: provisioning.KindCounts → a skill 4/5 · agent 2/2 · hook 1/1 line in cartographer status and the TUI.

Instructions (imprinting)

For every mounted KB, BuildManifest generates a kind: instructions artifact (name = KB name) whose content does not live on disk: it is produced by generateKBInstructions (pure and deterministic) and placed in Artifact.Files. It contains: a header (the KB is served via MCP) with the names of data/'s top-level archives inline (no page counts — it's stable imprinting, not state, D65), three lines of operational instructions (start with search/atlas_overview, read with concept_read, write with concept_write, close with log_append), the names only of the KB's agents (descriptions already come from the client's agent registry — the agent is installed natively, D65), and the verbatim content of the optional curated file <kbRoot>/instructions.md (at the root, never under data/). ContentHash = sha256 of the generated content: it changes only if the set of archives/agents or the curated file changes — not on every added page. Details → D56/D61 and D65.

Materialization: a managed block delimited by markers (<!-- cartographer:instructions:begin/end -->) inside the provider's global instructions file (see the matrix above) — a user file, never overwritten or deleted:

  • rewrite = replacement between the markers; missing markers → block appended at the end; missing file → created with only the block;
  • managed as a group: the block is the ordered concatenation of the snippets of all currently signed instructions — a removed KB disappears on the next rewrite; zero artifacts → block removed;
  • the signature gate applies as usual; lockfile: one ManagedFile per artifact (not per physical file);
  • pruning: removes only the block, never the file — except when the file is left empty (this covers kiro's dedicated file).

Path portability placeholders (D75)

Shared content (concepts and provisioning artifacts) must never contain machine-specific absolute paths: two placeholders, resolved client-side only, at materialization time:

  • {{repo:<name>}} (short form) / {{repo:<host>/<owner>/<name>}} (full, canonical form) — resolved automatically: the key is the normalized git remote (internal/repoindex, handles ssh scp-like, ssh://, https://, .git suffix), identical on every machine of the team. repoindex.Scan walks search_roots (client config, default ~/Documents) up to depth 4, reads each repo's .git/config (no git exec) and caches the result in ~/.config/cartographer/repos.json, refreshed on-miss.
  • {{path:<name>}} — manual paths: mapping in .cartographer.yaml, a fallback for directories that aren't git repos (and an override for {{repo:<key>}} too: repoindex.Resolve checks paths: before cache/scan).

No server-side expansion: it would break content_hash/if_match (hash on the raw content, content served differently per client) and the server doesn't know clients' filesystems. internal/mcpserver never sets ApplyOptions.ExpandPlaceholders — only cmd/cartographer (connect/sync/TUI) does, passing cfg.SearchRoots/cfg.Paths.

  • Expansion points (provisioning.Apply, before writing to disk): agent content (single-file), skill/hook content (every file in the folder), instructions content (per artifact, before composing the group block).
  • Hash on the expanded content: ManagedFile.ContentHash in the lockfile is computed on the content after expansion (same formula as ContentHashDir/contentHashFile/contentHashBytes depending on the kind) — the client-side comparison must be against what was actually written to disk. Content with no placeholders → expansion is a no-op, hash identical to Artifact.ContentHash: zero drift for existing installations.
  • Unresolved placeholder: a warning on stderr (AppliedResult.Warnings), the text left as-is in the file — sync never blocks on a missing resolution.
  • "Local paths" table: when ExpandPlaceholders is active, applyInstructionsGroup appends to the instructions block (per provider) a placeholder → local path table with every key resolved during that same Apply (agent/skill/hook included, not just the instructions content), plus a fallback instruction for the agent: a placeholder missing from the table → cartographer resolve <key> (docs/configurator.md).
  • Repo ambiguity: a short name matching several distinct remotes is an explicit error (it asks for the full form host/owner/name); multiple clones of the same remote resolve to the first match in search_roots order, with a warning.

MCP servers (D69, D116)

A KB can distribute third-party MCP servers (endpoints that connected agents must be able to call) alongside skills/agents/hooks/instructions. Two transports are supported: http (D69) and stdio (D116, see below); any other type fails the build.

  • KB layout: mcp/<name>.json, one file per server (single-file like agents, not a directory like skills). Provider-neutral schema {"type":"http","url","headers"?,"env"?} or {"type":"stdio","command","args"?,"env"?}. BuildManifest parses+validates here (parseMCPServerSpec): malformed JSON, an unknown field, an unsupported type, a missing url/command, a field belonging to the other transport, or a headers/env value that doesn't reference a ${VAR} (looks like a literal secret) fail the build, not the apply. No mcp/ folder → zero artifacts (backward-compatible).
  • No secrets in the file: headers and env values only support ${VAR} references, resolved by the client against its own environment at materialization time — the same pattern as token_env (D64). On an "http" server env is validated but not emitted: none of the 4 providers exposes a verified native channel for generic env vars there — only the Authorization: Bearer ${VAR} header can be reliably represented. On a "stdio" server every provider does have a native env channel, so references are emitted (D116).
  • Emission (internal/configurator.EmitServer): a refactor that extracts from Emit/ServerConfig (Cartographer's own entry) a provider-neutral core EmitServer(name, spec ServerSpec, provider), reused both by connect (for the cartographer entry) and by Apply for KB servers. Translation of the ${VAR} reference: claude/kiro/codex leave it verbatim (native syntax); OpenCode translates it to {env:VAR}. Codex only exposes bearer_token_env_var for auth: an Authorization: Bearer ${VAR} header is translated there, any other header is not representable and is dropped with a warning (EmitResult.Warnings) instead of failing. Kiro has never had a header field for MCP servers (a pre-existing limitation, not introduced by D69): a KB server with headers generates a warning in AppliedResult.Warnings, not an error.
  • Apply: unlike skills/agents/hooks, an MCP server does not materialize its own file/folder — it merges into the provider's native config (internal/provisioning/mcpsettings.go, registerMCPServer/removeMCPServer), with per-name ownership so multiple MCP servers (and Cartographer's own entry) coexist in the same file: claude/opencode/kiro do a JSON merge on the mcpServers/mcp key; codex uses a marker-delimited block # cartographer:mcp:<name>:begin/end (distinct from the unnamed block cartographer connect writes for itself) via internal/blocktext. destDir("mcp", ...) resolves to the same shared file for all servers of that provider (like instructions), but — unlike instructions (a GROUP block) — each server is an independent ManagedFile.
  • Prune/disconnect: PruneManaged strips only that name's entry/block (never the whole file), with the same empty-shell cleanup as configurator.Remove (D63): .kiro/settings/mcp.json/opencode.json are deleted if the removal leaves only $schema/nothing; .claude.json is never deleted; .codex/config.toml only loses the marker-delimited block.
  • Server policy and client consent: an MCP server receives the agent's data, so two independent grants apply. The server omits it unless the KB's exact mcp_allowlist entry matches name, transport and target — the normalized URL for http, the exact command for stdio; absent/empty policy denies every descriptor. On the client an unsigned descriptor requires cartographer approve mcp <name> --kb <kb>, binding consent to source, name and full content hash. trust/--auto-trust never authorize MCP. A cryptographically verified descriptor is eligible without a point approval, but is still subject to the server allow-list. Any hash change returns it to needs_approval; revoke then sync prunes managed provider config.
  • Remote sync: no per-kind filtering in sync_pull/tools_sync.go nor in the HTTP client — an mcp artifact travels as a single ArtifactFile (the mcp/<name>.json file), same schema as an agent.

Stdio descriptors (D116). A descriptor may use {"type":"stdio","command","args"?,"env"?}, which rejects url and headers. The command is a bare executable name resolved through PATH or a clean absolute path — never a shell expression: Cartographer passes command and arguments separately and never starts a shell. Arguments keep their order and env values are ${VAR} references only, so no secret value ever reaches the server or a provider file. The allow-list binds the exact command, while the artifact content hash binds every argument and reference: changing any field invalidates a D115 approval. Before any provider config or lockfile is written, PreflightStdioMCP resolves the command locally for every target provider (PATH lookup for bare names, executable regular-file check for absolute paths) and fails the whole sync naming the provider it protected; the resolved path is not persisted and the executable is never launched. Claude Code, Codex and Kiro receive native command/args/env; OpenCode receives type: "local", an ordered command array and environment with ${VAR} translated to {env:VAR}. A field a provider cannot represent fails instead of being silently dropped, so no provider ever runs a command different from the approved one.

Implementation choices

  • Dedup by kind+name: a skill present both in the bundle and in a KB is materialized only once; precedence KB > bundle, and among multiple KBs by alphabetical source. The manifest holds exactly one artifact per kind+name.
  • Lockfile: <base-dir>/.cartographer-sync.lock.json (v2 multi-provider).
  • Pruning is per tracked file, not per whole directory.

Out of scope

  • Bidirectional sync (client → server): the flow is unidirectional, the server is the source of truth.
  • Dynamic secret rotation/manager (→ skills-services-secrets.md).
  • Conflicts on skill content (that's git, KB-side → concurrency.md).