Skip to content

Sync a memory from a Git repo

A Hadron memory can pull its node graph from a GitHub repository. You author nodes as Markdown files with YAML frontmatter, push to GitHub, and sync — every change is version-controlled and the repo becomes the source of truth for the memory's content.

This guide covers the repo layout Hadron expects, GitHub authentication, the sync triggers and what they do, conflict behavior, and how to convert an existing memory to Git-backed.

When this is the right shape

Reach for Git-sync when:

  • The memory's content is authored in long form — domain reference, knowledge-base articles, decision records, agent prompts.
  • You want review on memory changes before they land — pull requests are the natural review surface.
  • The memory's content benefits from history — who changed what, when, why.

It's the wrong shape when:

  • The memory is mostly machine-written by agents at chat-time (per-user personal memories, chat history, extracted facts) — those should stay database-resident.
  • The content needs to be encrypted at rest — a plaintext GitHub repo undoes the encryption, even if the platform allows the configuration.
  • You need cross-memory edges that a flat repo can't express — Hadron tracks unresolved cross-repo references in a pending-edge table, but heavy reliance on them suggests the memory boundary is wrong.

Prerequisites

  • A GitHub repository you control (private is fine).
  • A Hadron organization with the GitHub App installed on the repo. Without an installation, the platform has no token to clone with. Public repos are not a special case — they still require the App installation.
  • Org admin role on the Hadron org.

Step 1: Lay out the repo

Hadron treats the repository as an Obsidian-style vault of nodes — one self-contained Markdown file per node, with YAML frontmatter for metadata and edges. (You can browse the result in Obsidian — see Use Obsidian to view/edit memory.)

A minimum repo:

.
├── README.md                ← repo root: the memory manifest
├── architecture.md
├── decisions.md             ← parent node "decisions"
├── decisions/
│   ├── pagination-buffer-size.md
│   └── retention-policy.md
├── glossary.md              ← parent node "glossary"
└── glossary/
    ├── stage.md
    └── topic.md

Each .md file becomes a node. Frontmatter carries metadata, and edges to other nodes live inline under nodes::

---
id: 019d-7e7c-… # UUIDv7, optional — generated on first sync if absent
name: Pagination buffer size
description: Why /api/users caps page size at 500.
tags: [pagination, api]
seq: 10
nodes:
  - id: 019d-9f2a-…
    loc: decisions:retention-policy
    rel: relates-to
---

# Pagination buffer size

The buffer was set when /api/users was an in-memory list. As of
2026-02 the endpoint is Postgres-backed and the cap is misleading…

Set type: when the node isn't the default info (which is omitted); see Node types for the full set.

A node with children is written as <name>.md beside its <name>/ folder — decisions.md sits next to decisions/. The file is the node; the folder holds its children. Because a node's path doesn't change when it gains or loses children, renames don't churn the graph. The only README.md is at the repo root, where it carries the memory's manifest (URN, name, description) rather than a node.

Edges

Edges live inline in each node's frontmatter, under a nodes: array — so a node is one self-contained file:

nodes:
  - id: 019d-9f2a-…                  # target node's id
    loc: decisions:retention-policy  # target's loc, for readability
    rel: relates-to                  # the relationship label
  - id: 019d-3c44-…
    loc: glossary:stage
    rel: defines

The importer resolves each edge by the target's id; the loc rides along for human readability. Add condition: (a JSONLogic expression) to gate an edge or priority: (lower fires first) to order resolution — both round-trip through the repo. Cross-repo references — pointing at a node in a different memory — are valid; they sit in the platform's pending-edge table and resolve once the target is synced into its memory.

Back-compat for hand-authors. Hadron still imports two older layouts even though it no longer writes them: parent nodes as <folder>/README.md, and edges in a .<file-stem>/edges.yaml sidecar beside the node. If both a sidecar and an inline nodes: array exist for a node, the sidecar wins — so when you migrate a node to the inline format, delete its old sidecar. The oldest format (directoryName/.node/node.yaml) also still loads. New repos should use the self-contained frontmatter shape above; the older formats get no new features.

Step 2: Authenticate

Hadron's GitHub integration uses a GitHub App model:

  1. Install the Hadron GitHub App on the repo (or on the org that owns it). The App is the same one your Hadron org's admin uses for any other Git-sync; you don't install one per memory.
  2. The Hadron org records the installation ID on its Organization.githubInstallationId field at install time. From then on, every sync mints a short-lived access token (RS256-signed JWT → 1-hour token) and clones with it.
  3. Self-hosted Hadron: an org can override the App by setting githubAppId and an encrypted githubAppPrivateKey on its Organization row, or by setting GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY env vars on the server. The lookup order is org-specific → env-var fallback.

If a sync returns a clone error, the App probably isn't installed on the target repo or the installation has been revoked. Re-install via the GitHub UI.

Step 3: Connect the memory

In the portal:

  1. Open the memory's detail page → Settings.
  2. Click Edit.
  3. Expand the GitHub integration section.
  4. Fill in:
  5. Repository URLhttps://github.com/<owner>/<repo> or the SSH form. HTTPS is preferred.
  6. Read branch — the branch to clone. Default main.
  7. Write branch — only set this if you'll push back from Hadron (see Pushing back). For pure read-only sync, leave it blank.
  8. Save. The memory's source, readBranch, and writeBranch fields are now set; nothing has been cloned yet.

If you don't see the GitHub integration section, your role on the org is below admin. The memory edit form gates it on org admin.

Step 4: First sync

  1. On the same memory page, click Sync from Git. The same action is available via the h-sync-graph MCP tool if you'd rather drive it from a coding agent.
  2. The server clones the repo to its on-disk cache, walks the tree, and upserts every node and edge it finds.
  3. The page refreshes; the Sync status row shows OK, the Last synced timestamp updates, and any pending cross-repo edges show in Pending edges.

If the sync fails:

  • syncStatus = ERROR and syncError carries the message. Common causes: repo not found (App not installed), branch not found (typo in readBranch), invalid frontmatter on a file (missing required fields).
  • The memory's existing nodes are untouched on failure — sync is all-or-nothing for the upsert phase.

What sync does to existing nodes

Hadron treats Git as the source of truth for any memory that has a source set:

  • Nodes in the repo are upserted by (loc, memoryId).
  • Nodes that exist in the database but not in the repo are deleted. There is no merge, no diff, no per-file intervention.

This is destructive on purpose — Git-backed memories are meant to mirror the repo. If you have nodes in the database you want to keep, add them to the repo before the next sync, or remove the memory's source to disconnect it.

Soft-deletes are honoured the same way they would be from any other write path: the nodes go to the trash and are recoverable within the configured window.

Step 5: Author and re-sync

The normal authoring loop:

  1. Edit a node's Markdown file in your editor (or in the GitHub web UI).
  2. Commit and push.
  3. In the portal, click Sync from Git — or call h-sync-graph with the memory's URN.
  4. Confirm the change in the memory's node browser.

There's no webhook, no polling, no auto-sync in v1. Every sync is explicit. If you want CI-driven sync, wire your repo's post-merge action to call the syncMemory GraphQL mutation.

Pushing back

If you set writeBranch, the memory exposes a Push to Git button on the same Settings page. The button serialises the current memory tree back to Markdown + YAML, writes it on the write branch, and commits with the org's GitHub App identity.

Two important constraints:

  • The push mutation (pushMemoryToGit) is admin-only.
  • There is no UI for setting up periodic auto-push in v1. Push-to-Git is for occasional flushes (e.g., agent-curated knowledge that has stabilised), not for every node mutation.

Converting an existing memory

A memory created without a Git source can be converted later:

  1. Open the memory → SettingsEdit.
  2. Fill in the GitHub integration fields.
  3. Save. (The memory's existing nodes are still untouched at this point.)
  4. Important: before clicking Sync, export the existing nodes to the repo first. Otherwise the first sync will delete every database-only node (per the rule above).

The simplest export path is to call pushMemoryToGit once (admin) — it serialises the current memory to the write branch. Open a PR to merge that into the read branch, then sync. After that, the repo and the memory are aligned and the normal edit-and-sync loop applies.

Watching sync state

Every memory carries four sync-state fields (visible on the Settings page or via the Memory GraphQL type):

Field What it tells you
syncStatus PENDING, SYNCING, OK, or ERROR.
syncError Free-text error message when status is ERROR. Plain text for now — no structured error code.
lastSyncedAt Timestamp of the last successful sync.
pendingEdgeCount Cross-repo edges that haven't been resolved yet.

A non-zero pendingEdgeCount is normal during a multi-memory rollout — the targets simply haven't been synced into their own memories yet. Re-sync those memories and the count drops.

Limitations

  • Binary assets are not synced. Assets uploaded via the asset-upload tool live in object storage, not the database; Git-sync only walks Markdown + YAML.
  • Encrypted memories can technically have a Git source, but syncing plaintext content into a public or shared repo defeats the encryption. Treat encrypted memories as database-resident.
  • Personal memories can technically have a Git source, but the data is per-user — putting it in a shared repo crosses the privacy boundary. Don't do this without an explicit reason.
  • No webhook ingestion. Every sync is explicit (h-sync-graph or the portal button). Hook your CI up manually if you want automation.
  • No per-file conflict resolution. Sync is upsert-or-delete by (loc, memoryId). Drift handling is out of scope for v1 — if you need it, manage divergence with PR review on the repo side, not in the platform.

Troubleshooting

Symptom What to check
Sync fails with repository not found The Hadron GitHub App isn't installed on the target repo, or the install was revoked. Re-install.
Sync succeeds but nodes don't appear Check syncStatus = OK and read the node browser. If files in the repo lack required frontmatter (name), they're skipped silently.
Pre-existing database nodes vanished Expected — see What sync does to existing nodes. The memory is now Git-mirrored; bring nodes into the repo before sync.
Edges break after a rename Edges resolve by the target's id, which is stable across renames — keep the id in each nodes: entry and edges survive. A renamed file does get a new loc, so update any citations that point at it.
pendingEdgeCount keeps climbing Cross-repo references whose targets aren't synced yet. Sync the target memories; the resolver passes auto-resolve on next sync.
Push-to-Git button missing Either writeBranch is unset or your role is below admin.