Configuration Sync to Git

The Workspace can mirror its administrative configuration — schemas, roles, permissions, connectors, scheduled tasks and other definitions — into a Git repository. Every change made by an admin is committed automatically, with the author and an optional message attached. When a remote is configured, the same commits are pushed upstream so the repository becomes the audit trail and the disaster-recovery copy of the workspace configuration.

Use it to:

  • Track who changed which configuration setting, and when.
  • Promote configuration from staging to production by pulling the same commits into another workspace.
  • Recover a known-good configuration after a misconfiguration or a restore.
Scope

Only administrative configuration (the "definitions" exported by the workspace) is tracked. Graph content, ingested documents, embeddings and search indexes are not stored in this repository — those belong to backups. See Backup and restore.

How it works

On startup, when MSK_GIT_TRACK_STATE_PATH is set, the workspace opens (or initializes) a Git repository at that path and checks out the configured branch. From then on, every tracked administrative change triggers an export of the current configuration into the repository's definitions/ folder, followed by a commit with the author set to the user who made the change.

If MSK_GIT_REMOTE_URL is also set, the workspace pushes the branch upstream after each commit using the supplied credentials.

The export is incremental. Only the definition files whose source actually changed are regenerated and rewritten, rather than re-formatting every endpoint, AI tool, task and index and rewriting the whole definitions/ tree on each tracked change. Unchanged files keep their bytes, so git's stat cache survives and repeated configuration edits sync noticeably faster on a large workspace. A file the export no longer produces is deleted from the tree. The first sync after a restart regenerates the tree once, and the byte comparison keeps that off git.

Required environment variables

Variable Required Default Description
MSK_GIT_TRACK_STATE_PATH yes Absolute path to the local directory used as the Git working tree. Created and git init-ed on first start if it doesn't already contain a .git folder.
MSK_GIT_TRACK_STATE_BRANCH no main Branch that receives the configuration commits.
MSK_GIT_REMOTE_URL no HTTPS URL of the remote repository to push commits to. When unset, history stays local.
MSK_GIT_REMOTE_USERNAME conditional Username for the remote. Required when MSK_GIT_REMOTE_URL is set and the remote requires authentication.
MSK_GIT_REMOTE_PASSWORD conditional Password or personal access token for the remote. Required alongside MSK_GIT_REMOTE_USERNAME.
Persist the repository path

MSK_GIT_TRACK_STATE_PATH must point to a location that survives container restarts. In Docker or Kubernetes, mount a volume at that path — otherwise the repository is recreated on every start and the history is lost.

Credentials

Use a service account or a personal access token scoped to the single configuration repository — not a long-lived user credential.

  • GitHub: a fine-grained PAT with Contents: read & write on the target repository works as the password. Set MSK_GIT_REMOTE_USERNAME to any non-empty string (GitHub ignores it for PAT auth).
  • GitLab: a project access token with the Developer role and the write_repository scope.
  • Azure DevOps: a PAT with Code: Read & Write. Use the PAT as the password.

What ends up in the repository

The first commit on a fresh repository contains only a README.md placeholder. Subsequent commits add and update files under definitions/. The full layout is regenerated from the live workspace on every change, so the working tree always reflects the workspace's current configuration — there are no partial edits.

The tree mirrors the definitions export:

Folder Contents
definitions/code/ Endpoints, AI tools, AI agents, AI assistants, skills, AI prompt templates, AI providers, entity post-processing, scheduled / data-connector / migration tasks, and custom code and search indexes.
definitions/config/ Node schemas (as C# classes, under config/schemas/nodes/), the edge-schema list, node styles and renderers, other index settings, chat policies, chat-policy enforcement settings, secret declarations, access-group declarations, installed packages, and the search, interface, file-indexing and general settings files.
definitions/nlp/ Spotters, pattern spotters, pipelines, and entity-linking configuration.

A few of those carry rules worth knowing before you read a diff:

  • Chat policies get one file per policy under config/chat-policies, written as the same definition the admin interface edits, with enum values written as names so a diff stays readable. The built-in policies are seeded from code on every boot and stay out of the repository.
  • Chat-policy enforcement settings — everything governing the batched sweep — live in config/chat-policy-enforcement.json.
  • Deep research maximum limits (concurrent runs, queued runs, concurrent runs per user, iterations, duration) ride along in config/general.json.
  • Secrets export their declaration only: name and description, under config/secrets. The value is written as the literal NOT EXPORTED, so a reader of the file or of the diff can see it was withheld rather than missed. Declarations travel because code that reads a secret only compiles where the declaration exists.
  • Custom AI assistants carry their chat prompt, the tools, agents and skills they pin, and whether they are shared. The built-in admin assistant is seeded from code and stays out.
  • The built-in consult tool is exported once a workspace has edited it; a workspace still running the shipped code re-seeds it on boot instead.
  • A node schema is a C# class, one file per node type under config/schemas/nodes/<Type>.cs. The class is the node type and its properties are its fields, spelled the way the graph generates them (List<T>, Dictionary<string, T>, List<List<T>>); [schema: Curiosity.Schemas.Key("…")] names the key field. A node type the workspace owns rather than an admin declares carries [schema: Curiosity.Schemas.InternalNodeType], declares no schema, and lists only the fields that are restricted.
  • Access control sits on the thing it restricts. [schema: Curiosity.Schemas.AccessGroup("<group uid>")] on the class restricts the whole node type; the same attribute on a property restricts that one field. The group's name is written beside it as a comment, which the reader skips, so a diff is readable. The old NodeTypeAccessControl and FieldAccessControl entries in config/general.json are read-only legacy — a bundle taken before the move still imports, but nothing writes them any more.
  • Access groups travel as declarations, one config/access-groups/<name>.<uid>.json per group, carrying its UID, name, description, hidden flag and scope — and never its membership, which belongs to the workspace it is imported into. Only groups in the shared, name-derived namespace are exported: the built-in public and private groups, and the groups derived from a user, a company or a node (a user's memories or archives, an SSO company, the group behind a shared node) are not workspace configuration and stay out.

Each commit message follows the pattern:

Configuration Sync (<optional context provided by the change>)

The author is the user who triggered the change (full name and email taken from their workspace profile). The committer is always the Curiosity Workspace service identity.

Do not commit manually

The repository is regenerated by the workspace on every tracked change. Manual commits or edits to the working tree will be overwritten the next time an admin changes configuration. Treat the repository as read-only on the remote side and use it for review and audit only.

Importing the definitions into another workspace

Promoting a configuration means feeding the same definitions/ tree into the target workspace as a definitions bundle. What the import does with it:

  • Overwriting an existing definition is recorded. An import that replaces an endpoint, AI tool, prompt template, entity post-processing, code / data-connector / migration task, AI provider, agent or skill leaves the same change-history entry a hand edit does, attributed to whoever ran the import, so the change can be reviewed, diffed and rolled back from the History view. Creating a definition the workspace did not have is deliberately not recorded — there is nothing to diff against.
  • A live secret is never overwritten. A secret the target already has is left completely untouched, values included. A secret it lacks is created with an empty value and named in the import warnings; set its value under Manage → Configure → Secrets before the code that reads it runs.
  • Access groups import first, before anything that can be restricted to one. A restriction naming a group the target workspace does not have is still applied — failing closed is the safe way for it to fail — and the missing group is named in the import warnings. Deleting an access group by importing a file is refused on purpose: it would revoke access for everyone in it, on a workspace whose people the bundle knows nothing about.
  • An import replaces a definition's access groups, it does not add to them. Taking a group off an endpoint, an AI tool or an AI assistant's public access in the source workspace is applied to the target, instead of leaving the target granting access the bundle no longer describes.
  • An assistant may pin something the target lacks. If an imported AI assistant pins a tool, agent or skill that is not in the target workspace, the assistant is imported and the missing item is reported as a warning rather than failing the whole bundle. Include the pinned definitions in the same bundle to avoid it.

Removing a definition

An export says what a workspace has. To say what it should stop having, a file in the bundle can instead be a deletion: it carries a Deleted marker plus whatever identifies its target, and nothing else. Importing such a bundle removes the definition rather than being purely additive, so a definition that has to go no longer has to be deleted by hand in the admin pages afterwards.

The marker is one more of the [target: Curiosity.Area.Property] attributes every definition file already carries, and for the JSON kinds it is "Deleted": true:

Kind Marker Identified by
Endpoint [endpoint: Curiosity.Endpoints.Deleted] its Path[endpoint: Curiosity.Endpoints.Path("...")] — since an endpoint's UID is derived from it
AI tool [tools: Curiosity.ChatAITools.Deleted] its UID
Entity post-processing [entityPostProcessing: Curiosity.EntityPostProcessing.Deleted] its UID
Code / data-connector task [task: Curiosity.Tasks.Deleted] its UID
Migration [migration: Curiosity.Tasks.Deleted] its UID
AI provider [provider: Curiosity.ChatAI.Deleted] its UID
Prompt template [prompt-template: Curiosity.AIPromptTemplates.Deleted] its UID
Agent [agent: Curiosity.Agents.Deleted] its UID
AI assistant [assistant: Curiosity.Assistants.Deleted] its UID
Skill [skill: Curiosity.Skills.Deleted] its UID
Chat policy "Deleted": true its "UID"
Secret "Deleted": true its "Name"
NLP spotter / pattern / pipeline, node style, node renderer the marker alone the file name, the way the importer reads them back

Because identity is read with a single-attribute match rather than the full reader for the kind, a deletion can also be written by hand.

Three behaviours to count on:

  • A built-in is refused, before the workspace even checks whether it exists — it is refused for what it is, and a workspace that has not seeded it yet does not make deleting it reasonable.
  • A UID of the wrong node type is left alone, with an error, rather than deleting an unrelated part of the workspace.
  • Naming something that is already absent is a warning, not an error, so the same bundle is safe to import twice.

An index, a schema, a package or a configuration document is not one node that can be taken away, and a deletion naming one says so rather than appearing to succeed.

Renaming a code endpoint

A code endpoint is the one definition whose node UID is derived from its path, so renaming one is not an edit: it is a new node, and the old one goes on answering the old route. Every other kind is identified by a UID that survives a rename.

So an endpoint file can say what it was renamed from:

[endpoint: Curiosity.Endpoints.Path("/reports/quarterly")]
[endpoint: Curiosity.Endpoints.PreviousPath("/reports/quarter")]

The attribute is stored on the node, so a workspace that performed a rename re-exports it and carries the rename onward to the next workspace the bundle reaches. An endpoint nobody renamed exports exactly as it did before — the attribute is written only when it is set.

The import applies renames in a second pass, once every endpoint in the bundle has been written, because whether the old route may be removed depends on what else the same import carried. Three things it never removes:

  • A route the same import also wrote. An endpoint that took the old route over is a different endpoint, not the one being renamed away.
  • A UID holding something that is not a code endpoint — that is an error, and the node is left alone.
  • The renamed endpoint itself. The UID is a case-insensitive hash of the path, so "renaming" /Foo to /foo is one and the same node.

Removing the old route is reported as a warning, because anything still calling it now gets a 404. Naming a route that is already gone is not an error, so the bundle stays safe to import twice.

In the admin editor this is automatic: saving an endpoint under a new path records the rename. In the admin assistant a rename is three things in one commit — change Path, mv the file to match, and add the attribute.

The import file list is a tree

The importer's file list is shown in the shape of the bundle's own folders, with checkbox, Ctrl-click and Shift-click selection and cascading folder selection, so a whole folder can be picked in one gesture. A plain click still opens the file's diff. Files with nothing to import are shown but not selectable, and an import the server rejects can be retried without reopening the modal.

Two import failure modes that no longer occur: a tool or agent whose description spans several paragraphs used to break the parse of its file and fail the entire import, and skills could not be created or renamed through the WebDAV definitions mount at /dav/ because the folder was missing from its layout.

Troubleshooting

Symptom Likely cause Fix
Repository is recreated on every restart MSK_GIT_TRACK_STATE_PATH points to a non-persistent location (e.g. inside the container's writable layer). Mount a volume at that path.
Commits land locally but are never pushed MSK_GIT_REMOTE_URL is unset, or credentials are wrong. Set the remote URL and verify the username/password against the provider's auth requirements.
repository path not owned by current user in the logs Mounted volume is owned by a different UID inside the container. The workspace already disables LibGit2's owner validation; if you still see this, check filesystem permissions on the mount.
Commits appear with no author name The user who made the change has no profile name or email set. Update the user's profile in the workspace; future commits use the new identity.

See also: Configuration, Security, Backup and restore.

© 2026 Curiosity. All rights reserved.
Powered by Neko