Skip to content

Control Plane — Go server and MCP tools

Rationale

Go: static binary, performant on I/O across many files, containerizable. MCP: an abstraction layer that keeps the system agnostic to the agent (Claude Code, Codex, Kiro, OpenCode and Antigravity all speak the same protocol; Hermes receives artifacts through its deployment integration).

A (nearly) stateless server

For each mounted KB, the server keeps only a rebuildable derived index and a parsing cache. Everything else lives in the files. Multiple KBs per instance: each with its own index, lock, and git repo.

Read/write boundary

  • Concepts are read and written through MCP tools. Writes validate parseable frontmatter, required fields, path/layout constraints and strict-map type palettes.
  • if_match protects updates from stale overwrites.
  • With git auto-commit enabled, each successful logical write produces one commit. A write does not implicitly append to log.md; use log_append when a chronological record is wanted.
  • Read-only tools carry Tool.ReadOnly=true; HTTP scopes enforce the read/write boundary.

Commit per logical operation (Step 1 — local commit): every write tool (concept_write, concept_new, concept_patch, map_create, map_delete, concept_expand, asset_write, asset_delete, log_append, snapshot, supersede, concept_move, concept_batch, concept_delete, conflict_resolve, skill_install) is wrapped by gitWrap, which acquires the per-KB mutex, runs the tool, and on success (no application error) calls CommitOp. A failed commit does not turn a successful operation into an error: it is logged to stderr. AutoCommit=false (the struct's zero value) leaves everything unchanged and keeps compatibility with existing tests. concept_batch (D125) is the one write tool that additionally rolls back its own already-written files on a late failure (an index-update error after every file succeeded) — see concurrency.md §Writer boundary.

Read freshness across instances (D93): when git.sync is enabled and a KB has an origin, every tool marked [R] piggybacks a fetch + pull-rebase before handling the read, at most once per git.in_window per KB. A pull that moves HEAD reconciles the derived indexes before the read, so a remote concept becomes both readable and searchable. Read-side sync is best-effort: a fetch error is logged and serves the local replica; a rebase conflict is registered and its concepts are marked degraded, then the read still serves the local tree. There is no background poller.

MCP API

This list is the source of truth for the active tools (do not duplicate counts elsewhere).

Tools marked [R] have Tool.ReadOnly=true (internal/mcpserver): they never mutate KB content and remain callable with a kb:<name>:r scope. All others require rw. Enforcement and scope format → transport-auth.md §Per-KB authorization.

Beyond that read/write split, every tool is also classified by the resource it addresses — exact concept, collection, source/destination, or whole KB — which is what a fine-grained role is evaluated against (D118). Collection tools filter their results per element before applying any limit; exact-resource tools collapse "forbidden" and "missing" into the same generic not found. A tool missing from that classification is denied outright, so adding a tool without choosing its resource semantics fails closed rather than granting access. The table and the guarantees are in transport-auth.md §Roles and fine-grained permissions. The same [R] tools expose annotations: {"readOnlyHint": true} in tools/list per the MCP spec (D76): a client can use this to auto-approve reads without a manual allowlist, without having to derive the list by hand.

Tools marked [A] (advanced, advancedToolNames in internal/mcpserver/visibility.go) are hidden from tools/list in the default agent tool profile (D65): operator maintenance/mutation and provisioning plumbing that would bloat the LLM agent's context without being useful in a normal session. They all remain callable via tools/call by name (CLI client, hooks, operator) in both profiles. Profile: tools.profile in YAML / CARTOGRAPHER_TOOLS_PROFILE / --tools-profile, values agent (default) | fulldeployment.md.

Agent governance vs. operator maintenance (D123). validate, lint, gate_check, and kb_status are read-only governance diagnostics, not operator maintenance: the documented agent loop (loop.md, use-cases.md) runs them every session, and a descriptor-bound MCP host (one that can only invoke tools tools/list advertises, e.g. Codex) cannot call them by name the way a stdio/TUI agent can. They are therefore part of the default agent profile's core set, unlike commit_gate, conflict_resolve, contradiction_report, and the rest of advancedToolNames, which stay operator-only and advanced.

The kb argument on a routed mount (D187). On the /mcp/routed endpoint of a server with mcp.mount_mode: routed, every tool below carries one extra property: kb, the Knowledge Base the call is for. It is required whenever the routed mount serves two or more KBs — the KB is never inferred — and optional when it serves exactly one, where there is nothing to disambiguate. A call without it is refused with an error naming the mounted KBs. The advertised set is the union of what the mounted KBs register, so a tool one KB gates off is still listed and is refused at dispatch for that KB, with an error naming the tool, the KB and the setting. On the per-KB endpoints (/mcp?kb= and /mcp/<name>) no kb argument exists and nothing below changes. See transport-auth.md §Mount modes.

Reading and navigation

Tool Purpose
atlas_overview() [R] Root index + maps/journals (concept count, and expanded concept count if any).
map_list() [R] Lists maps and journals with metadata (kind, ontology_mode, concept_types, expanded concept count).
index_get(path, [with_hash], [outline], [full]) [R] Reads a folder's index.md (progressive disclosure). By default returns raw Markdown verbatim, byte-for-byte, unchanged for any existing caller. with_hash: true instead returns structured {path, content, content_hash}; content_hash is the if_match index_patch expects (D122). Size guard (D185): an index over 60 KB without outline or full{path, content_hash, outline, content_bytes, note} and no content, the same threshold and shape concept_read has used since D78; full: true forces the whole content, outline: true returns the heading outline at any size.
concept_read(id, [section], [outline], [full], [with_content]) [R] Reads a concept or a single section (bounded). The full response is {id, content_hash, frontmatter_raw, body} — the concept's text appears once, as body (D185). with_content: true adds content (frontmatter + body, the exact bytes on disk), which only a caller that will re-write the file verbatim needs; it is ignored by the section, outline and size-guard responses, which never carried it. Returns the content-hash for if_match. A section not found → error with the list of available headings (capped at 50), no guessing. outline=true returns only the structure ({level, title, bytes} per heading) with no content. Size guard: a body over 60 KB without section or outline → returned as outline plus a note (no content), unless full=true forces the full content (D78).
log_tail(path, [n]) [R] Latest N entries relevant to path. Empty path = the verbatim root log. A non-empty path has no log.md of its own (see log_append): it filters root entries prefixed [<path>], preceded by any entries of a pre-existing <path>/log.md. No entries → a JSON note {"entries": 0, "note": "..."}, never a silent empty string (D78).
changes_since([since], [limit]) [R] Git-history digest since an RFC3339 timestamp or <N>d/<N>h duration (default 7d). Returns each changed concept's newest status (added, modified, deleted, moved), latest timestamp, authors and recent operations; map/log/artifact changes are counted as other_changes. Results default to 100 concepts (maximum 500) and report truncated; no commits returns a JSON note rather than an empty string (D94).
graph_neighbors(id, [depth], [direction]) [R] Graph neighbors (used for lint scoping). direction is out (default, links from the page), in (backlinks to it), or both; traversal returns minimum distances and can start from a missing target for backlink inspection. The graph sees both markdown links [text](rel.md) (relative to the file) and wiki-links [[id]]/[[id#section]] (root-relative, D72 WP0). Ask direction: "in" before editing a runbook to see which pages cite it.
concept_list([scope], [limit], [where], [timestamp_before], [timestamp_after]) [R] Exhaustive inventory: {id, title, type} for every concept under the scope prefix (empty = the whole KB, including services/), ordered by id. where is an ANDed list of case-sensitive key=value / key!=value frontmatter predicates (a list matches any element; a missing key matches != only); timestamp bounds are strict RFC3339 or YYYY-MM-DD comparisons. Filters run before limit; filtered responses report examined, and timestamp queries also report skipped timestamps. Ask where: ["tags=istio"] to list everything tagged istio. limit defaults to 500, with truncated/total if exceeded. An exhaustive alternative to index_get's progressive disclosure (D72 WP3).
Tool Purpose
search(query, [scope], [limit]) [R] Keyword search — the only mode (D135): mode and use_semantic are no longer accepted and a call passing either is rejected. Keyword matching first requires all query terms, then retries with any term if that returns no hits. Every hit includes title (from the frontmatter) and snippet (an excerpt of ~200 chars around the match; FTS5 uses its native snippet(), otherwise it's extracted in-memory) — avoids a concept_read just to judge a hit's relevance (D70). scope is a literal id prefix on both backends: _ and % match themselves, not the SQL LIKE wildcards they would otherwise be.
reindex([full]) [A] Reconciles the derived FTS5 and in-memory search indexes with concepts changed outside MCP. Use after imports or manual edits; returns indexed, updated, and removed. With full: true it rebuilds the whole index from every concept instead, returning status: "rebuilt", concepts_indexed and — only with a SQLite index — sql_upserted; that mode also works when no SQLite index is available, while the incremental one needs the persisted state to compare against and otherwise reports SQLite index is unavailable. It requires write scope because it writes the server-owned SQLite index (D90, D136).

Writing and ingest

Tool Purpose
map_create(name, title, [kind], [concept_types], [ontology_mode], [required_fields], [required_fields_by_type], [require_index_entry], [machine_path_allow_prefixes]) Creates a map (kind: map, default) or a journal (kind: journal): a directory with _map.md, index.md, log.md. The optional contract fields are serialized deterministically in its descriptor.
map_delete(map) Deletes a map/journal directory, but only if it holds nothing beyond the map_create scaffold (_map.md, index.md, log.md); if any concept remains, errors listing them — move them out with concept_move first, then retry (D88).
concept_expand(id) Promotes a concept to an expanded concept: map/name.mdmap/name/index.md, same ConceptID (no backlink rewrite), from which it can grow with map/name/child satellites. Requires a 2-segment id; errors not_found / already_expanded. No inverse operation (D77).
asset_read(concept_id, path, [encoding]) [R] Reads a non-Markdown asset inside an expanded concept. Returns content (text or base64; invalid UTF-8 is always base64), raw-byte sha256, size and executable mode.
asset_list(concept_id) [R] Lists an expanded concept's non-Markdown regular assets with path, size, raw-byte sha256, and executable mode.
asset_write(concept_id, path, content, [encoding], [executable], [if_match]) Creates or updates an expanded concept asset. 1 MiB decoded limit; .md, hidden, escaping and symlink paths are rejected. if_match is forbidden on create and mandatory on overwrite; omitted executable defaults false on create and preserves mode on overwrite.
asset_delete(concept_id, path, if_match) [A] Deletes an asset with mandatory raw-byte if_match, pruning empty asset-only directories but never the owner directory.
concept_write(id, frontmatter, body, [mode], if_match) Creates/updates with validation. if_match = expected content-hash; fails with stale_write if changed. Automatically updates the in-memory keyword index and, if present, the persisted FTS5 index (no reindex needed).
concept_new(template, id, [vars]) Creates a new concept from templates/<slug>.md, with literal single-pass {{identifier}} substitution in frontmatter values and body. Refuses existing IDs and missing/extra variables; updates indexes and makes one commit. It creates only the concept (no curated index entry) and does not pre-check a strict-map ontology because templates can serve multiple maps.
concept_patch(id, old_string, new_string, [replace_all], if_match, [frontmatter]) String-replace patch on the body only (Edit-like semantics), without rewriting the entire concept. if_match is mandatory: fails with stale_write if changed. Fails with old_string_not_found or old_string_ambiguous (use replace_all for multiple matches). frontmatter, if present, is shallow-merged onto the existing frontmatter; a key set to null is removed rather than set to a literal null (fails if the key is required, e.g. type, D88). Same write path (indices, commit) as concept_write (D70). As an alternative to the old_string/new_string/replace_all triple, it accepts an edits: [{old_string, new_string, replace_all?}] field to apply several patches in a single call (one commit): the two forms are mutually exclusive; edits are applied in order, atomically (edit i+1 sees the result of edit i) — if one fails, nothing is written and the error reports the index of the failed edit (D76).
index_patch(path, old_string, new_string, [replace_all], if_match) The same bounded Edit-like patch as concept_patch, applied to the root or a Map/Journal's curated index.md instead of a concept (D122). Accepts only the root (path empty) or an existing Map/Journal's index — never an arbitrary nested path, never a reserved file other than index.md; a two-segment path naming an expanded concept's own index (e.g. map/concept) is rejected with expanded_index, pointing at concept_patch(id=<owner>) instead, since that index is a concept, not a curated collection index. if_match is mandatory (read it with index_get(with_hash: true) first): fails with stale_write if changed. Same old_string/edits batch semantics as concept_patch, one commit per call, one root log.md entry. Never touches the live/SQLite concept search indexes — root/Map indexes are curated prose, not indexed concepts.
log_append(entry, [path]) Appends an entry to the root log (never a per-directory log). With path, the entry is prefixed [<path>] and still written to root; log_tail(path) retrieves it by filtering on that prefix (D78).
snapshot([message]) Records an entry in log.md. With git auto-commit enabled, it also creates a git commit of the entire KB.
supersede(source_id, target_id, [reason]) Marks a concept as superseded by another.
concept_move(source_id, target_id \| moves[], [rewrite_links]) Moves one or more concepts (batch moves: [{source_id, target_id}], single form kept for backward compatibility; the two forms cannot be mixed). Validates the entire batch before applying any move (application-level atomicity), one git commit per call. With rewrite_links (default true) it rewrites backlinks across the whole KB — wiki-links [[old]]/[[old#section]] and relative markdown links — in a single pass using the old→new map, and updates the indices (in-memory + FTS5) for both the moved and rewritten concepts; the result lists the applied moves and rewritten concepts. With rewrite_links=false backlinks are left intact (warning in the result, use lint). D72 WP1/WP2.
concept_batch(operations) [A] Atomically writes/patches several distinct concepts as one logical operation: one git commit, one summary log.md entry, either every operation applies or none does (D125). Each entry is write (frontmatter, body, optional if_match — absent means create-only, required to update an existing concept) or patch (required if_match, optional frontmatter shallow-merge, the same single/edits Edit-tool semantics as concept_patch). Bounded: capped operation count and aggregate content size, duplicate/invalid IDs rejected; every operation — including each Map's strict-ontology palette and required-field contract — is validated before anything is written. Delete, move, expand, assets, and Map/root curated indexes stay out of scope: use concept_delete/concept_move/concept_expand/asset_*/index_patch for those. For confined single-concept edits use concept_patch's own edits batch (D76) instead; for renames use concept_move (D72); this tool is for a multi-page refactor that touches several concepts at once, where separate concept_write/concept_patch calls would leave partially-aligned intermediate commits if interrupted.
concept_delete(id, [if_match], [force]) Permanently removes a concept from the KB (git commit). An expanded owner with assets refuses deletion until force: true; force deletes assets and the owner index but preserves satellite Markdown concepts. Incoming backlinks are not updated — use lint to find them.

Governance

Tool Purpose
validate(scope) [R] OKF compliance (frontmatter, type, reserved files).
lint([scope], [scope_neighbors], [severity_min]) [R] Runs deterministic broken-link, stale-claim, orphan, contract and structural checks. scope_neighbors=true adds one-hop graph neighbors. There is no model-backed/deep mode. severity_min (info/warning/error) floors the returned findings — default info, exhaustive, because lint is the tool an operator calls to see findings (D186). count is always the unfiltered total, and counts_by_check/counts_by_severity are computed before filtering, so a caller always knows the shape of what it is not being shown.
commit_gate() [A] Blocks when open Contradictions are involved in the diff.
gate_check(changed_ids, [severity_min], [scope], [scope_neighbors]) [R] Combines validate + lint + commit_gate in a single tool (lightweight local gate). severity_min defaults to warning here (D186): the info checks cannot fail a gate, so an agent that wants them asks. scope narrows both the validation and the lint to a path prefix — empty, the default, gates the whole KB; the caller owns that choice and the tool never infers it from changed_ids, which would silently narrow a gate callers rely on being archive-wide. pass is computed on the unfiltered, whole-scope results, so a severity floor can never turn a failing gate into a passing one; validation_errors and gate_blockers are never filtered. The response carries lint_count, findings_omitted, counts_by_check and counts_by_severity.
conflict_resolve(contradiction_id, resolution, [reason]) [A] Closes an open Contradiction.
contradiction_report([scope], [status]) [A] Lists contradictions, filterable by scope and status.
kb_status() [R] The kb field names the KB that served the call (D144). Aggregate metrics: total concepts, per type, per status (concepts with no status field are excluded, D131), stale ones, open contradictions. Plus the replication facts an agent needs without reaching for sync_status (D145): has_remote / remote_url (origin, credentials redacted), git_sync, push_state, push_last_error, unpushed_commits (null when no trustworthy remote-tracking comparison exists), and the write workflow git_workflow (local | server, D117 — it says nothing about remote presence). Read-only: no fetch, no network. git_profile is a deprecated alias of git_workflow, kept for one minor release.
conflicts_list() [R] Lists open git rebase conflicts (read-only). For each entry: concept_id, local/remote SHAs, branch, files involved, detected_at, resolution guidance. See also the kb-conflict-resolve skill.
git_conflict_resolve(concept_id, strategy, [body]) Resolves a registered conflict (Step 4). strategy: ours (local version), theirs (remote version), edit (full content in body). Records the per-concept decision; once every open conflict is resolved, it performs a single merge+commit+push and clears the degraded markers. See concurrency.md §Step 4.

Skills and Services

Tool Purpose
skill_list() [R] [A] Lists the installed skills (skills/) and the ones bundled in the binary. Field source: [installed] | [bundled].
skill_install(name, force) [A] Copies a bundled skill into kb.Root/skills/<name>/. Errors if already present; force=true overwrites.
service_get(service_id, resolve_secrets=false) [R] [A] Reads a Service. With resolve_secrets: true, resolves declared secret_refs (or legacy whole secrets_source) and requires rw scope.
secret_resolve(concept_id, names?) [RW] [A] Resolves SOPS references declared by any concept; names narrows the declared values.
secret_set(path, key, value) [RW] [A] Sets a JSON Pointer in an existing encrypted SOPS file; plaintext is never committed or returned.
service_list() [R] [A] Lists all concepts of type Service.

Client ↔ provisioning synchronization

Tool Purpose
sync_check([applied_revision]) [R] [A] Read-only. Returns the current manifest's revision and artifact list, including cryptographic signed verification output and separate built_in trust origin.
sync_apply(base_dir, [dry_run], [auto_trust]) [A] Materializes verified or built-in artifacts into base_dir; auto_trust is explicit unsigned authorization and never sets signed. A local signer is verified before applying. It also verifies the managed files on disk and restores what diverged, reporting those artifacts as healed (D139); there is no opt-out argument, since this path has no interactive user.
sync_pull() [R] [A] Read-only, no parameters. Returns base64 file contents plus optional detached Ed25519 signature (algorithm, key_id, envelope version, value). Remote clients recompute hashes and verify pinned keys before materialization. KB-provided MCP descriptors appear only when the server-side allow-list permits them.
sync_status() [R] [A] Read-only. Returns local Git replication state (disabled, no_remote, clean, pending, or failed), the last error/attempt, HEAD, best-effort unpushed count, identity warning, push mode, and the write workflow git_workflow (D145; profile is its deprecated alias, kept for one minor release).
pr_status() [R] [A] Read-only, server Git profile only (D117). Returns the profile, base/working branches, current PR identity and last forge error; reconciles a merge_uncertain state first. Never returns the forge token.
pr_finalize(head_sha) [A] Server Git profile only (D117). Squash-merges the open PR after checking that head_sha matches the current remote head, reviews/checks are satisfied, and a fresh rebase plus validation succeed. Force-with-lease touches only the working branch; the base is never pushed.

KB-root artifacts

Tool Purpose
artifact_read(path, [encoding]) [R] Reads a KB-root artifact file (skills/<slug>/**, agents/<slug>.md, hooks/**, mcp/<slug>.json, instructions.md, templates/<slug>.md) and returns its content, encoding (text or base64) and raw-byte sha256 (the if_match for artifact_write). Non-UTF-8 bytes are always base64.
artifact_list() [R] [A] Lists provisioning artifacts and KB-only templates by kind, with files, raw-byte sha256 and executable metadata. Templates are scanned directly because they are absent from provisioning.BuildManifest.
template_list() [R] Agent-visible template discovery: returns each template's slug, literal type, title and sorted variables, never its body.
artifact_write(path, content, [encoding], [executable], [if_match]) Creates/updates a KB-root artifact file. encoding is text (default) or base64; the 256 KiB limit applies after decoding. executable is tri-state: omitted preserves an existing mode and defaults false on creation. Structured principal files remain UTF-8 and non-executable. Templates require parseable frontmatter with a literal non-empty type and only {{identifier}} variables in scalar/list values or body. On an existing file, if_match (raw-byte sha256) is mandatory (already_exists if missing, stale_write if wrong); per-kind validation applies before the write. Only registered if the KB has allow_artifact_write: true. This is the write end of what a materialized skill or agent advertises: their provenance block (D138) names this tool, the KB and the path to call it with.
artifact_delete(path, if_match) [A] Removes a KB-root artifact file (and an eligible empty directory). Same per-KB flag as artifact_write.

artifact_write/artifact_delete go through gitWrap (lock, commit, sync) and notify skills/list_changed when the path is under skills/. The per-KB flag → deployment.md.

See docs/sync.md for the full model (Manifest, Lock, Diff, layered triggers).

Multi-KB: which KB a tool call reaches is decided per-connection, not per-call — ?kb=<name> (query param) or /mcp/<name> (path) select one KB's isolated Server for the whole session; no tool takes a kb argument. Every KB exposes the same tool names by default, which flat-namespace MCP clients (e.g. Kiro) cannot disambiguate across servers — see tool_prefix in deployment.md §MCP tool-name prefix.

Enforced limits

Each of these is a hard limit a caller hits as a failure unless it reads it first, so every one is stated in the description of the tool that enforces it, interpolated from the constant the handler checks — a figure and its documentation cannot drift apart.

Limit Constant Enforced by
1 MiB per asset kb.AssetMaxFileSize (internal/kb/asset.go) asset_write, asset_read
50 operations per batch conceptBatchMaxOps (internal/mcpserver/tools_write.go) concept_batch
512 KiB aggregate per batch conceptBatchMaxTotalBytes (same file) concept_batch
500 lines per skill body maxSkillBodyLines (internal/skill/skill.go) skill lint (warning)

required_fields, required_fields_by_type and require_index_entry are lint contracts, not write gates: a violation is a lint/validate finding and does not fail concept_write. The one map contract that does fail a write is ontology_mode: strict, which makes validate reject an out-of-vocabulary type. That asymmetry is what made the whole area confusing, so each description now says which kind it is.

Search index

Rebuildable index (vault = truth, index = disposable), with two persistence levels:

  • In-memory (default/Core): a pure-Go keyword inverted index (internal/search). Rebuilt on every startup by walking the concepts.
  • Persisted SQLite (internal/sqlindex, D32): when the KB has an openable .cartographer/index.db, keyword search uses FTS5 with a trigram tokenizer (supports substrings, not just whole words). Multi-term matching tries all terms first, then any term only if the all-terms search is empty; terms shorter than three characters are omitted from the FTS match. At boot, after a git pull that moves HEAD, and on reindex, content hashes reconcile external additions, changes, and removals without a restart; reindex(full: true) rebuilds every concept instead of diffing (D136). On this path, the search response's mode field is keyword_fts5. A database created before D135 may still carry an embeddings table: it is never read nor written, and needs no migration. Best-effort: if the DB can't be opened or FTS5 is unavailable, it degrades to the in-memory path. At small scale index.md and keyword search can be enough: keyword search is the only search mode the server offers (D135).

Validation and invariants

  • validate checks parseable frontmatter, a non-empty type, reserved files, layout/depth rules and the allowed type palette of strict Maps.
  • lint reports deterministic findings. It does not generate typed graph edges or Contradiction concepts.
  • Contract findings are missing_required_field (error), index_incomplete (warning), and contract_malformed (info). A lint error makes gate_check fail; commit_gate remains contradiction-only.
  • commit_gate inspects existing type: Contradiction concepts with resolution_status: open and blocks a supplied set of changed concept IDs when they are involved.
  • Broken links are tolerated on write and surfaced by lint.

Provenance, history and audit

Provenance and citation sections are KB conventions; the server does not require them on every concept. log.md is updated only through explicit log tools. Git commits are the durable change history when auto-commit is enabled.

internal/audit implements a JSONL hash-chain with optional Ed25519 signatures. When audit.log is configured, every tools/call appends an attempt event before dispatch and a completion event after it (D119), so the log is a complete operational audit trail: semantics, failure modes and the cartographer audit verify|export commands are in transport-auth.md §Operational audit.

The content hash is computed on normalized content and per section to avoid spurious stale_write failures.