> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.livingbrain.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.livingbrain.com/_mcp/server.

# Importing existing knowledge

You don't have to start a brain from nothing. Notes you've already written and what your AI assistants
already remember can go in on the first day. The brain compiles them into connected pages, the same way
it handles new captures, and every page links back to the note it came from.

| Bring in                 | How                                                                                                                          |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| An Obsidian vault        | A script sends every note in the vault to the brain. See [Obsidian vault](#obsidian-vault).                                  |
| Claude or ChatGPT memory | Ask the assistant to write out what it remembers, then save it. See [Claude and ChatGPT memory](#claude-and-chatgpt-memory). |
| A Claude project         | Save the project's instructions, files and memory. See [Claude projects](#claude-projects).                                  |

## Before you import

* **Fill in the [brain profile](/docs/concepts#brain-profile) first.** It shapes how captures are compiled,
  but only the ones that arrive after it.
* **Try a small sample.** Import part of your material into a test brain, check the pages, then run the
  rest. Each capture is compiled by a model, which counts toward your usage.
* **Give each capture an `originRef`.** The brain skips a capture whose `originRef` it has already
  compiled, so you can run an import again without creating duplicates.
* **Mind the limits.** One capture holds up to 50,000 characters. A batch request holds up to 100
  captures on most plans.

## Obsidian vault

An Obsidian vault is a folder of Markdown files, so each note can go in as one capture. Frontmatter,
tags and `[[links]]` stay in the text, and the brain uses them along with the rest of the note when it
connects pages.

The script below sends every note in a vault:

* It skips `.obsidian`, `.trash` and other hidden folders, and empty notes.
* It splits a note longer than 50,000 characters at paragraph breaks.
* It uses the note's path as the label, so each page's sources show which note they came from.
* It sets `originRef` to the note's path plus a hash of its text. When you run the script again, the
  brain skips notes you haven't changed and captures the ones you've edited, then updates their pages.
* It skips files that aren't UTF-8 text, and tells you which ones.

Save it as a file. It needs Python 3.9 or later, or Node 20.12 or later, and no packages.

**`import_obsidian.py`**

```python import_obsidian.py
"""Import an Obsidian vault into a Living Brain.

Usage: python import_obsidian.py <vault folder> <brain id> [--dry-run]
Needs LIVING_BRAIN_API_KEY (and LIVING_BRAIN_SUBJECT_ID if your key isn't tied to one subject).
"""
import hashlib
import json
import os
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path

API = "https://api.livingbrain.com/v1"
MAX_CHARS = 50_000  # the API's limit for one capture
BATCH_SIZE = 100  # the per-request limit on most plans


def split_note(text):
    """Split text longer than MAX_CHARS at paragraph breaks."""
    if len(text) <= MAX_CHARS:
        return [text]
    parts, current = [], ""
    for paragraph in text.split("\n\n"):
        if current and len(current) + len(paragraph) + 2 > MAX_CHARS:
            parts.append(current)
            current = ""
        current = f"{current}\n\n{paragraph}" if current else paragraph
        while len(current) > MAX_CHARS:  # a single paragraph over the limit
            parts.append(current[:MAX_CHARS])
            current = current[MAX_CHARS:]
    return parts + [current] if current else parts


def vault_items(vault):
    notes = (p for p in vault.rglob("*") if p.suffix.lower() == ".md" and p.is_file() and not p.is_symlink())
    for path in sorted(notes):
        relative = path.relative_to(vault)
        # Skip .obsidian (settings), .trash and any other hidden folder.
        if any(part.startswith(".") for part in relative.parts):
            continue
        try:
            text = path.read_bytes().decode("utf-8-sig")
        except UnicodeDecodeError:
            print(f"  skipped {relative}: not UTF-8 text")
            continue
        # Normalize Windows line endings so the paragraph split and hashes match on every OS.
        text = text.replace("\r\n", "\n").replace("\r", "\n").strip()
        if not text:
            continue
        name = relative.with_suffix("").as_posix()
        parts = split_note(text)
        for index, part in enumerate(parts, start=1):
            digest = hashlib.sha256(part.encode("utf-8")).hexdigest()[:16]
            suffix = f" (part {index} of {len(parts)})" if len(parts) > 1 else ""
            yield {
                "kind": "note",
                "content": part,
                "label": f"{name}{suffix}"[:256],
                # The brain skips a capture whose originRef it has already compiled.
                # The hash of the text means an edited note is captured again.
                "originRef": f"obsidian:{index}:{digest}:{vault.name}/{name}"[:512],
            }


def post_batch(brain_id, items):
    headers = {"x-api-key": os.environ["LIVING_BRAIN_API_KEY"], "content-type": "application/json"}
    if os.environ.get("LIVING_BRAIN_SUBJECT_ID"):
        headers["x-subject-id"] = os.environ["LIVING_BRAIN_SUBJECT_ID"]
    body = json.dumps({"items": items}).encode("utf-8")
    for attempt in range(5):
        request = urllib.request.Request(
            f"{API}/brains/{brain_id}/captures/batch", data=body, headers=headers, method="POST"
        )
        try:
            with urllib.request.urlopen(request, timeout=120) as response:
                return json.load(response)
        except urllib.error.HTTPError as error:
            # Retry rate limits and server errors; anything else needs fixing first.
            if (error.code != 429 and error.code < 500) or attempt == 4:
                raise SystemExit(f"HTTP {error.code}: {error.read().decode('utf-8', 'replace')}")
            retry_after = error.headers.get("retry-after", "")
            time.sleep(int(retry_after) if retry_after.isdigit() else 30)
        except (urllib.error.URLError, TimeoutError) as error:
            if attempt == 4:
                raise SystemExit(f"Network error: {error}")
            time.sleep(30)


def main():
    args = [arg for arg in sys.argv[1:] if arg != "--dry-run"]
    if len(args) != 2:
        raise SystemExit(__doc__)
    vault, brain_id = Path(os.path.abspath(os.path.expanduser(args[0]))), args[1]
    items = list(vault_items(vault))
    print(f"{len(items)} captures from {vault}")
    if "--dry-run" in sys.argv:
        for item in items:
            print(f"  {item['label']}  ({len(item['content']):,} characters)")
        return
    if not os.environ.get("LIVING_BRAIN_API_KEY"):
        raise SystemExit("Set LIVING_BRAIN_API_KEY first.")
    for start in range(0, len(items), BATCH_SIZE):
        batch = items[start : start + BATCH_SIZE]
        post_batch(brain_id, batch)
        print(f"  sent {start + len(batch)} of {len(items)}")


if __name__ == "__main__":
    main()
```

**`import-obsidian.mjs`**

```javascript import-obsidian.mjs
// Import an Obsidian vault into a Living Brain.
// Usage: node import-obsidian.mjs <vault folder> <brain id> [--dry-run]
// Needs LIVING_BRAIN_API_KEY (and LIVING_BRAIN_SUBJECT_ID if your key isn't tied to one subject).
import { createHash } from "node:crypto";
import { readdir, readFile } from "node:fs/promises";
import { basename, join, relative, resolve, sep } from "node:path";

const API = "https://api.livingbrain.com/v1";
const MAX_CHARS = 50_000; // the API's limit for one capture
const BATCH_SIZE = 100; // the per-request limit on most plans

// Split text longer than MAX_CHARS at paragraph breaks. Lengths count characters
// (code points), as the API does, rather than UTF-16 units.
function splitNote(text) {
  const length = (s) => [...s].length;
  if (length(text) <= MAX_CHARS) return [text];
  const parts = [];
  let current = "";
  for (const paragraph of text.split("\n\n")) {
    if (current && length(current) + length(paragraph) + 2 > MAX_CHARS) {
      parts.push(current);
      current = "";
    }
    current = current ? `${current}\n\n${paragraph}` : paragraph;
    while (length(current) > MAX_CHARS) {
      // a single paragraph over the limit
      const chars = [...current];
      parts.push(chars.slice(0, MAX_CHARS).join(""));
      current = chars.slice(MAX_CHARS).join("");
    }
  }
  return current ? [...parts, current] : parts;
}

async function vaultItems(vault) {
  const entries = await readdir(vault, { recursive: true, withFileTypes: true });
  const files = entries
    .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md"))
    .map((entry) => relative(vault, join(entry.parentPath, entry.name)))
    // Skip .obsidian (settings), .trash and any other hidden folder.
    .filter((path) => !path.split(sep).some((part) => part.startsWith(".")))
    .sort();
  const decoder = new TextDecoder("utf-8", { fatal: true }); // also drops a leading BOM
  const items = [];
  for (const path of files) {
    let text;
    try {
      text = decoder.decode(await readFile(join(vault, path)));
    } catch {
      console.log(`  skipped ${path}: not UTF-8 text`);
      continue;
    }
    // Normalize Windows line endings so the paragraph split and hashes match on every OS.
    text = text.replace(/\r\n?/g, "\n").trim();
    if (!text) continue;
    const name = path.split(sep).join("/").replace(/\.md$/i, "");
    const parts = splitNote(text);
    parts.forEach((part, i) => {
      const digest = createHash("sha256").update(part).digest("hex").slice(0, 16);
      const suffix = parts.length > 1 ? ` (part ${i + 1} of ${parts.length})` : "";
      items.push({
        kind: "note",
        content: part,
        label: [...`${name}${suffix}`].slice(0, 256).join(""),
        // The brain skips a capture whose originRef it has already compiled.
        // The hash of the text means an edited note is captured again.
        originRef: [...`obsidian:${i + 1}:${digest}:${basename(vault)}/${name}`].slice(0, 512).join(""),
      });
    });
  }
  return items;
}

async function postBatch(brainId, items) {
  const headers = { "x-api-key": process.env.LIVING_BRAIN_API_KEY, "content-type": "application/json" };
  if (process.env.LIVING_BRAIN_SUBJECT_ID) headers["x-subject-id"] = process.env.LIVING_BRAIN_SUBJECT_ID;
  const pause = (seconds) => new Promise((done) => setTimeout(done, seconds * 1000));
  for (let attempt = 0; attempt < 5; attempt++) {
    let response;
    try {
      response = await fetch(`${API}/brains/${brainId}/captures/batch`, {
        method: "POST",
        headers,
        body: JSON.stringify({ items }),
        signal: AbortSignal.timeout(120_000),
      });
    } catch (error) {
      if (attempt === 4) throw new Error(`Network error: ${error.message}`);
      await pause(30);
      continue;
    }
    if (response.ok) return response.json();
    // Retry rate limits and server errors; anything else needs fixing first.
    if ((response.status !== 429 && response.status < 500) || attempt === 4) {
      throw new Error(`HTTP ${response.status}: ${await response.text()}`);
    }
    const retryAfter = response.headers.get("retry-after") ?? "";
    await pause(/^\d+$/.test(retryAfter) ? Number(retryAfter) : 30);
  }
}

const args = process.argv.slice(2).filter((arg) => arg !== "--dry-run");
if (args.length !== 2) {
  console.error("Usage: node import-obsidian.mjs <vault folder> <brain id> [--dry-run]");
  process.exit(1);
}
const vault = resolve(args[0]);
const items = await vaultItems(vault);
console.log(`${items.length} captures from ${vault}`);
if (process.argv.includes("--dry-run")) {
  for (const item of items) console.log(`  ${item.label}  (${[...item.content].length.toLocaleString()} characters)`);
} else {
  if (!process.env.LIVING_BRAIN_API_KEY) {
    console.error("Set LIVING_BRAIN_API_KEY first.");
    process.exit(1);
  }
  for (let start = 0; start < items.length; start += BATCH_SIZE) {
    const batch = items.slice(start, start + BATCH_SIZE);
    await postBatch(args[1], batch);
    console.log(`  sent ${start + batch.length} of ${items.length}`);
  }
}
```

Then run it:

#### Find the brain ID

List your brains and copy the `id` of the one to import into. To keep the vault separate, create a
new brain for it first.

```bash
curl https://api.livingbrain.com/v1/brains -H "x-api-key: YOUR_API_KEY"
```

#### Preview the import

Set your API key, then list what would be sent without sending it:

**`Python`**

```bash Python
export LIVING_BRAIN_API_KEY="YOUR_API_KEY"
python import_obsidian.py ~/Documents/MyVault YOUR_BRAIN_ID --dry-run
```

**`Node.js`**

```bash Node.js
export LIVING_BRAIN_API_KEY="YOUR_API_KEY"
node import-obsidian.mjs ~/Documents/MyVault YOUR_BRAIN_ID --dry-run
```

In Windows PowerShell, set the key with `$env:LIVING_BRAIN_API_KEY = "YOUR_API_KEY"` instead. If
your API key isn't tied to one subject, also set `LIVING_BRAIN_SUBJECT_ID`. See
[Authentication](/docs/authentication).

#### Run the import

Run the same command without `--dry-run`. Captures compile in the background, so pages appear over
the next few minutes. [Track the sources](/docs/capturing#tracking-ingestion) to find any that failed.
If the script stops partway, run it again. Notes that were already sent are skipped.

To keep the brain up to date, run the script again whenever you like, for example on a schedule. The
brain only compiles notes it hasn't seen in their current form. Deleting or renaming a note in the vault
doesn't remove what the brain already compiled from it.

The script only reads Markdown files, so images, PDFs and other attachments stay behind. To add a PDF,
capture it with `kind: "file"` and a `fetchUrl`, a link the brain can download, such as a temporary
signed link from your file storage. See [Capturing knowledge](/docs/capturing).

## Claude and ChatGPT memory

Claude and ChatGPT keep a memory of you: your work, projects, preferences and instructions. The simplest
way to move it is to ask the assistant to write out what it remembers.

#### Ask the assistant to write out its memory

Start a new chat and send:

```text
Write out everything you remember about me from your memory, exactly as stored: my work,
projects, preferences, tools and any instructions I've given you. Put one item per line in a
single code block, with the date you learned it if you know it. Then tell me if the list is
complete.
```

To see what the assistant has stored, open its memory settings. At the time of writing, that's
**Settings > Your Claude**, then **Manage** next to **Your memory**, in Claude, and
**Settings > Personalization > Memory** in ChatGPT.

#### Read the list

Compare the list with the memory settings, since an assistant can leave items out. Remove anything
you don't want in the brain before you save it.

#### Save it to the brain

If the assistant is [connected to Living Brain](/mcp/sign-in), ask it to save the list itself:

```text
Save that list to my Living Brain called "Personal".
```

It saves the list with `living_brain_capture`. Otherwise, paste the list into a file such as
`ai-memory/chatgpt-memory.md` and run the [vault script](#obsidian-vault) on the `ai-memory` folder.
When you import an updated list later, the script captures the new version.

## Claude projects

A Claude project has three parts worth keeping:

| Part                             | How to bring it in                                                                                                                                                           |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Instructions                     | Copy them from the project page into a Markdown file.                                                                                                                        |
| Files in the project's knowledge | Use the original files. Save text files as Markdown. Capture PDF, Word (`.docx`) and PowerPoint (`.pptx`) files with `kind: "file"` and a `fetchUrl` the brain can download. |
| Project memory                   | Inside the project, ask Claude to write out its memory, as in [Claude and ChatGPT memory](#claude-and-chatgpt-memory).                                                       |

Put each project's Markdown files in a folder and run the [vault script](#obsidian-vault) on it. To tell
projects apart, give each its own brain, or start each file name with the project's name, which then
appears in the source labels.

## Check the result

Open the brain on [livingbrain.com](https://livingbrain.com) to see the new pages and how they connect, or
ask a connected assistant about something from your notes. Each page lists the sources it came from, so
you can trace an answer back to the note or memory behind it.