Skip to content
Richard Šenko

How I Connected ChatGPT to Obsidian on TrueNAS

I wanted one simple workflow: give ChatGPT a transcript, let it turn the content into a structured note, and have that note appear automatically in my Obsidian vault. The result became a small self-hosted pipeline built with Custom GPT Actions, FastAPI, TrueNAS, Syncthing and Tailscale Funnel.

An AI conversation connected through a secure tunnel to a NAS, a Markdown note and a knowledge graph

The annoying part was not generating the note. ChatGPT could already turn a transcript or article into useful Markdown. The friction came at the very end: I still had to copy the result into Obsidian by hand.

I wanted that last step gone. A finished note should appear in my vault automatically, while Obsidian remained local and the files stayed ordinary Markdown.

That small annoyance turned into a self-hosted pipeline built with Custom GPT Actions, FastAPI, Docker, TrueNAS, Tailscale Funnel, Syncthing, and Obsidian. The interesting part is that Obsidian itself does not need a REST plugin, a custom plugin, or even to be running when a note is created.

The API simply works with the Markdown files inside the vault.

My final workflow looks like this:

Transcript / article / notes
                   ↓
               Custom GPT
                   ↓
        Search existing notes
                   ↓
        Structured Markdown
                   ↓
            GPT Action
                   ↓
         Tailscale Funnel
                   ↓
         FastAPI on TrueNAS
                   ↓
           NAS-side vault
                   ↓
              Syncthing
                   ↓
        Local Obsidian vault
        

The GPT can create notes, read and search the vault, append information, reuse existing concepts, build backlinks, and create small concept notes when useful.

One operation is intentionally missing: delete. If an automated system creates a bad note, deleting it manually is easy. Giving an internet-accessible automation recursive delete access to my knowledge base did not seem worth the risk.

Contents


Why I kept Obsidian local

An Obsidian vault is ultimately just a directory containing Markdown files, attachments, and an .obsidian folder. I could have stored the vault directly on TrueNAS and opened it over SMB.

I decided not to.

I wanted the desktop vault to keep working when the NAS was unavailable, retain normal local filesystem performance, and avoid making everyday note-taking dependent on network latency.

So I use two copies:

Windows local vault
               ⇅
            Syncthing
               ⇅
        TrueNAS vault
        

The TrueNAS copy is the one my API can access. Syncthing then carries changes in both directions.

This also keeps the responsibilities clean:

Component Responsibility
Custom GPT Understands source material and decides what to write
GPT Action Calls my REST API
Tailscale Funnel Provides the public HTTPS entry point
FastAPI Validates requests and reads/writes Markdown
TrueNAS Stores the NAS-side vault
Syncthing Synchronizes the vault
Obsidian Provides the local knowledge interface

That separation is important to me. ChatGPT is not tightly coupled to Obsidian, and Obsidian is not tightly coupled to TrueNAS. I could replace the model, synchronization layer, or API implementation without rebuilding the entire workflow.


What I used

My setup uses:

  • TrueNAS
  • Docker / TrueNAS Custom Apps
  • Python 3.12
  • FastAPI
  • Obsidian
  • Syncthing
  • Tailscale Funnel
  • ChatGPT Custom GPT Actions

The important paths and ports in my setup are:

NAS vault:
          /mnt/Apps/AppsData/ObsidianVault

        API project:
          /mnt/Apps/AppsData/obsidian-chatgpt

        Vault inside container:
          /vault

        FastAPI inside container:
          8000

        Published TrueNAS port:
          8765
        

So before exposing anything publicly, the API is available on the LAN at:

http://TRUENAS_IP:8765
        

Step 1: Sync the vault with Syncthing

I installed Syncthing on TrueNAS and Windows, paired both devices, and configured the Obsidian vault as a Send & Receive folder.

On TrueNAS I also enabled staggered file versioning. That gives me another recovery path if a synchronized change overwrites something useful.

The first problem: TrueNAS ACLs

This was the first issue I hit.

Syncthing transferred files, but the NAS side remained out of sync with errors such as:

chmod /vault/...: operation not permitted
        

The dataset was using TrueNAS ACLs while Syncthing was trying to reproduce Unix permission bits from the other system. I do not need filesystem permissions synchronized for an Obsidian vault, so the fix was:

Syncthing
        → Folder
        → Advanced
        → Ignore Permissions
        → Enabled
        

After enabling Ignore Permissions on the NAS-side folder, synchronization completed normally.


Step 2: Build a small REST bridge for the vault

Instead of building an Obsidian plugin, I wrote a small FastAPI service that talks directly to the filesystem.

It exposes five note operations:

Method Endpoint Action
POST /notes Create a note
POST /notes/append Append content
GET /notes/read Read one note
POST /notes/search Search notes
GET /notes/list List notes

There are also two public system endpoints:

Method Endpoint Purpose
GET /health Minimal health check
GET /privacy Privacy-policy endpoint for a public GPT using Actions

All note-related endpoints require Bearer authentication.

The project itself is deliberately small:

obsidian-chatgpt/
        ├── app/
        │   └── main.py
        ├── .env
        ├── Dockerfile
        ├── requirements.txt
        ├── docker-compose.yml
        └── action-openapi.yaml
        

My requirements.txt contains only:

fastapi>=0.116,<1.0
        uvicorn[standard]>=0.35,<1.0
        pydantic>=2.11,<3.0
        

There is no database. The vault is the datastore.

Complete FastAPI application

The entire bridge lives in app/main.py:

Show complete FastAPI application
from __future__ import annotations

        import json
        import os
        import re
        from datetime import datetime, timezone
        from pathlib import Path
        from typing import Literal

        from fastapi import Depends, FastAPI, Header, HTTPException, Query, status
        from fastapi.responses import PlainTextResponse
        from pydantic import BaseModel, Field, field_validator


        APP_TITLE = "Obsidian ChatGPT Bridge"

        API_KEY = os.getenv("API_KEY", "")
        VAULT_ROOT = Path(
            os.getenv("VAULT_PATH", "/vault")
        ).expanduser().resolve()

        MAX_NOTE_BYTES = int(
            os.getenv("MAX_NOTE_BYTES", "1000000")
        )


        app = FastAPI(
            title=APP_TITLE,
            version="1.0.0",
            description="Obsidian REST bridge",
            docs_url=None,
            redoc_url=None,
            openapi_url=None,
        )


        class CreateNoteRequest(BaseModel):
            title: str = Field(
                min_length=1,
                max_length=180,
                description="Human-readable note title without .md",
            )

            folder: str = Field(
                default="Inbox",
                max_length=500,
                description="Vault-relative folder, e.g. Oracle APEX/eRecept",
            )

            content: str = Field(
                default="",
                max_length=800_000,
                description="Markdown body. Do not include YAML front matter.",
            )

            tags: list[str] = Field(
                default_factory=list,
                max_length=30,
            )

            @field_validator("title")
            @classmethod
            def clean_title(cls, value: str) -> str:
                value = value.strip()

                if not value:
                    raise ValueError("title cannot be empty")

                return value

            @field_validator("tags")
            @classmethod
            def clean_tags(cls, values: list[str]) -> list[str]:
                cleaned: list[str] = []

                for tag in values:
                    tag = tag.strip().lstrip("#")

                    if tag and tag not in cleaned:
                        cleaned.append(tag[:80])

                return cleaned


        class AppendNoteRequest(BaseModel):
            path: str = Field(
                min_length=1,
                max_length=700,
                description="Vault-relative path ending in .md",
            )

            content: str = Field(
                min_length=1,
                max_length=800_000,
                description="Markdown text to append",
            )

            dedupe_key: str | None = Field(
                default=None,
                max_length=120,
                description=(
                    "Optional stable key. If already present, "
                    "the append is treated as already completed."
                ),
            )


        class SearchRequest(BaseModel):
            query: str = Field(
                min_length=1,
                max_length=300,
            )

            folder: str = Field(
                default="",
                max_length=500,
            )

            limit: int = Field(
                default=10,
                ge=1,
                le=50,
            )


        class NoteResult(BaseModel):
            success: bool
            path: str

            status: Literal[
                "created",
                "unchanged",
                "appended",
                "already_applied",
                "read",
            ] | None = None


        def require_auth(
            authorization: str | None = Header(default=None),
        ) -> None:

            if not API_KEY:
                raise HTTPException(
                    status_code=500,
                    detail="Server API_KEY is not configured",
                )

            expected = f"Bearer {API_KEY}"

            if authorization != expected:
                raise HTTPException(
                    status_code=status.HTTP_401_UNAUTHORIZED,
                    detail="Invalid API key",
                )


        def _safe_relative(raw: str) -> Path:
            raw = raw.strip().replace("\\", "/")

            path = Path(raw)

            if path.is_absolute():
                raise HTTPException(
                    status_code=400,
                    detail="Path must be vault-relative",
                )

            candidate = (VAULT_ROOT / path).resolve()

            try:
                candidate.relative_to(VAULT_ROOT)

            except ValueError as exc:
                raise HTTPException(
                    status_code=400,
                    detail="Path escapes the vault",
                ) from exc

            return candidate


        def _safe_filename(title: str) -> str:
            # Safe on Windows/Linux/macOS
            # and avoids accidental nested paths.

            value = re.sub(
                r'[<>:"/\\|?*\x00-\x1f]',
                "-",
                title,
            ).strip().rstrip(". ")

            value = re.sub(r"\s+", " ", value)

            if not value:
                raise HTTPException(
                    status_code=400,
                    detail="Title does not produce a valid filename",
                )

            return f"{value[:180]}.md"


        def _ensure_markdown(path: Path) -> None:
            if path.suffix.lower() != ".md":
                raise HTTPException(
                    status_code=400,
                    detail="Only .md files are allowed",
                )


        def _read_text(path: Path) -> str:
            if not path.exists() or not path.is_file():
                raise HTTPException(
                    status_code=404,
                    detail="Note not found",
                )

            if path.stat().st_size > MAX_NOTE_BYTES:
                raise HTTPException(
                    status_code=413,
                    detail="Note is too large",
                )

            return path.read_text(
                encoding="utf-8",
            )


        @app.get(
            "/health",
            operation_id="healthCheck",
            tags=["system"],
        )
        def health() -> dict:
            return {
                "ok": True,
            }


        @app.post(
            "/notes",
            operation_id="createNote",
            response_model=NoteResult,
            dependencies=[Depends(require_auth)],
            tags=["notes"],
            summary="Create an Obsidian Markdown note",
        )
        def create_note(
            note: CreateNoteRequest,
        ) -> NoteResult:

            folder_raw = (
                note.folder or "Inbox"
            ).strip().replace("\\", "/")

            folder = _safe_relative(folder_raw)

            folder.mkdir(
                parents=True,
                exist_ok=True,
            )

            path = _safe_relative(
                str(
                    Path(folder_raw)
                    / _safe_filename(note.title)
                )
            )

            _ensure_markdown(path)

            created = datetime.now(
                timezone.utc
            ).isoformat(
                timespec="seconds"
            )

            tags_yaml = json.dumps(
                note.tags,
                ensure_ascii=False,
            )

            markdown = (
                "---\n"
                f"created: {created}\n"
                f"tags: {tags_yaml}\n"
                "source: chatgpt\n"
                "---\n\n"
                f"{note.content.rstrip()}\n"
            )

            if len(
                markdown.encode("utf-8")
            ) > MAX_NOTE_BYTES:

                raise HTTPException(
                    status_code=413,
                    detail="Generated note is too large",
                )

            if path.exists():
                existing = _read_text(path)

                # Make retries idempotent even though
                # the generated created timestamp changes.

                expected_tail = (
                    f"{note.content.rstrip()}\n"
                )

                if existing.endswith(
                    expected_tail
                ):
                    return NoteResult(
                        success=True,
                        path=str(
                            path.relative_to(
                                VAULT_ROOT
                            )
                        ),
                        status="unchanged",
                    )

                raise HTTPException(
                    status_code=409,
                    detail=(
                        "A note with this title already exists. "
                        "Use appendNote or choose another title."
                    ),
                )

            path.write_text(
                markdown,
                encoding="utf-8",
            )

            return NoteResult(
                success=True,
                path=str(
                    path.relative_to(
                        VAULT_ROOT
                    )
                ),
                status="created",
            )


        @app.post(
            "/notes/append",
            operation_id="appendNote",
            response_model=NoteResult,
            dependencies=[Depends(require_auth)],
            tags=["notes"],
            summary="Append Markdown to an existing Obsidian note",
        )
        def append_note(
            req: AppendNoteRequest,
        ) -> NoteResult:

            path = _safe_relative(
                req.path
            )

            _ensure_markdown(path)

            existing = _read_text(path)

            marker = None

            if req.dedupe_key:
                safe_key = re.sub(
                    r"[^A-Za-z0-9._:-]",
                    "_",
                    req.dedupe_key,
                )

                marker = (
                    f"<!-- chatgpt-append:{safe_key} -->"
                )

                if marker in existing:
                    return NoteResult(
                        success=True,
                        path=str(
                            path.relative_to(
                                VAULT_ROOT
                            )
                        ),
                        status="already_applied",
                    )

            addition = (
                "\n\n"
                + req.content.strip()
                + "\n"
            )

            if marker:
                addition += marker + "\n"

            if len(
                (existing + addition).encode("utf-8")
            ) > MAX_NOTE_BYTES:

                raise HTTPException(
                    status_code=413,
                    detail="Resulting note would be too large",
                )

            with path.open(
                "a",
                encoding="utf-8",
            ) as file:
                file.write(addition)

            return NoteResult(
                success=True,
                path=str(
                    path.relative_to(
                        VAULT_ROOT
                    )
                ),
                status="appended",
            )


        @app.get(
            "/notes/read",
            operation_id="readNote",
            dependencies=[Depends(require_auth)],
            tags=["notes"],
            summary="Read one Obsidian note",
        )
        def read_note(
            path: str = Query(
                description="Vault-relative .md path"
            ),
        ) -> dict:

            note_path = _safe_relative(
                path
            )

            _ensure_markdown(
                note_path
            )

            content = _read_text(
                note_path
            )

            return {
                "path": str(
                    note_path.relative_to(
                        VAULT_ROOT
                    )
                ),
                "content": content,
            }


        @app.post(
            "/notes/search",
            operation_id="searchNotes",
            dependencies=[Depends(require_auth)],
            tags=["notes"],
            summary="Search Markdown notes in the vault",
        )
        def search_notes(
            req: SearchRequest,
        ) -> dict:

            root = (
                _safe_relative(req.folder)
                if req.folder
                else VAULT_ROOT
            )

            if not root.exists():
                return {
                    "query": req.query,
                    "results": [],
                }

            needle = req.query.casefold()

            results = []

            for path in root.rglob("*.md"):

                if (
                    not path.is_file()
                    or path.stat().st_size
                    > MAX_NOTE_BYTES
                ):
                    continue

                try:
                    text = path.read_text(
                        encoding="utf-8"
                    )

                except UnicodeDecodeError:
                    continue

                title_match = (
                    needle
                    in path.stem.casefold()
                )

                text_folded = (
                    text.casefold()
                )

                pos = text_folded.find(
                    needle
                )

                if (
                    pos < 0
                    and not title_match
                ):
                    continue

                if pos >= 0:
                    snippet_start = max(
                        0,
                        pos - 120,
                    )

                    snippet_end = min(
                        len(text),
                        pos
                        + len(req.query)
                        + 220,
                    )

                    snippet = (
                        text[
                            snippet_start:
                            snippet_end
                        ]
                        .replace("\n", " ")
                        .strip()
                    )

                else:
                    snippet = (
                        text[:320]
                        .replace("\n", " ")
                        .strip()
                    )

                results.append(
                    {
                        "path": str(
                            path.relative_to(
                                VAULT_ROOT
                            )
                        ),
                        "title": path.stem,
                        "snippet": snippet[:400],
                    }
                )

                if len(results) >= req.limit:
                    break

            return {
                "query": req.query,
                "results": results,
            }


        @app.get(
            "/notes/list",
            operation_id="listNotes",
            dependencies=[Depends(require_auth)],
            tags=["notes"],
            summary="List Markdown notes in a vault folder",
        )
        def list_notes(
            folder: str = Query(
                default="",
                description=(
                    "Vault-relative folder; "
                    "empty means whole vault"
                ),
            ),
            limit: int = Query(
                default=50,
                ge=1,
                le=200,
            ),
        ) -> dict:

            root = (
                _safe_relative(folder)
                if folder
                else VAULT_ROOT
            )

            if not root.exists():
                return {
                    "folder": folder,
                    "notes": [],
                }

            notes = []

            for path in sorted(
                root.rglob("*.md"),
                key=lambda p:
                    p.stat().st_mtime,
                reverse=True,
            ):

                notes.append(
                    {
                        "path": str(
                            path.relative_to(
                                VAULT_ROOT
                            )
                        ),
                        "title": path.stem,
                        "modified": datetime.fromtimestamp(
                            path.stat().st_mtime,
                            tz=timezone.utc,
                        ).isoformat(
                            timespec="seconds"
                        ),
                    }
                )

                if len(notes) >= limit:
                    break

            return {
                "folder": folder,
                "notes": notes,
            }


        @app.get(
            "/privacy",
            response_class=PlainTextResponse,
            include_in_schema=False,
        )
        def privacy() -> str:
            return (
                "This private Obsidian bridge stores request content "
                "as Markdown files in the configured vault. "
                "Vault data is returned only in response to authenticated "
                "API requests. When used through a ChatGPT Action, requested "
                "note content may be transmitted to ChatGPT as part of the "
                "action response. The bridge does not independently send vault "
                "data to other services. Access is protected by an API key."
            )
        

The design choices that matter

Most of the API is intentionally boring. That is a feature.

A few details are especially important:

  • Vault-relative paths only. A request such as ../../etc/passwd cannot escape the configured vault.
  • Markdown only. The service rejects non-.md targets instead of acting as a general remote filesystem.
  • No delete endpoint. The GPT can create and append, but not remove arbitrary files.
  • Safe filenames. Characters problematic across Windows, Linux, and macOS are sanitized.
  • File-size limits. Requests cannot create arbitrarily large notes.
  • Bearer authentication. Every operation that exposes note data requires the API key.
  • Idempotent create behavior. A retry with effectively identical content returns unchanged instead of creating another note.
  • Idempotent append behavior. An optional dedupe_key prevents the same append from being applied twice.

That last point matters more than it may seem. GPT Actions are network calls, and network calls can be retried. Without idempotency, a successful request followed by an uncertain response could result in duplicate content.

For appends, the API writes an invisible marker such as:

<!-- chatgpt-append:fastapi-observation-2026-08-22 -->
        

If the same operation arrives again, the marker is detected and the second append is skipped.

Search is intentionally simple

searchNotes performs case-insensitive lexical search over filenames and Markdown content. It also returns a short snippet around the match.

That is enough for my current backlink workflow: the GPT can inspect note titles first, then search for a specific concept only when it is ambiguous.

It is not semantic search. I come back to that limitation later.

API key and environment

I generated a long random key with Python:

python -c "import secrets; print(secrets.token_urlsafe(48))"
        

The actual key lives in .env:

API_KEY=YOUR_LONG_RANDOM_API_KEY
        

I do not commit it to Git.

The remaining configuration comes from Docker:

VAULT_PATH=/vault
        MAX_NOTE_BYTES=1000000
        

Step 3: Run the API as a TrueNAS Custom App

The API runs in Docker on TrueNAS.

My Dockerfile is:

FROM python:3.12-slim

        ENV PYTHONDONTWRITEBYTECODE=1 \
            PYTHONUNBUFFERED=1

        WORKDIR /app

        COPY requirements.txt .

        RUN pip install \
            --no-cache-dir \
            -r requirements.txt

        COPY --chown=568:568 app ./app

        RUN find /app -type d \
            -exec chmod 755 {} \; && \
            find /app -type f \
            -exec chmod 644 {} \;

        EXPOSE 8000

        CMD [
            "uvicorn",
            "app.main:app",
            "--host",
            "0.0.0.0",
            "--port",
            "8000"
        ]
        

And this is the TrueNAS Custom App configuration I actually deploy:

services:
          obsidian-api:

            build:
              context: /mnt/Apps/AppsData/obsidian-chatgpt
              dockerfile: Dockerfile

            container_name: obsidian-chatgpt

            env_file:
              - /mnt/Apps/AppsData/obsidian-chatgpt/.env

            environment:
              MAX_NOTE_BYTES: '1000000'
              VAULT_PATH: /vault

            image: obsidian-chatgpt-local:latest

            ports:
              - '8765:8000'

            pull_policy: build

            restart: unless-stopped

            user: '568:568'

            volumes:
              - source: /mnt/Apps/AppsData/ObsidianVault
                target: /vault
                type: bind
        

The important mapping is simple:

/mnt/Apps/AppsData/ObsidianVault
                      ↓
                    /vault
        

and:

TrueNAS :8765
             ↓
        Container :8000
        

The second problem: container permissions

My first builds repeatedly crashed with:

PermissionError: [Errno 13] Permission denied:
        /app/app/main.py
        

The container was running as UID/GID 568:568, but the copied Python files were not readable correctly by that user.

The fix is already present in the Dockerfile above:

COPY --chown=568:568 app ./app

        RUN find /app -type d     -exec chmod 755 {} \; &&     find /app -type f     -exec chmod 644 {} \;
        

I also had to force a clean image rebuild once because TrueNAS was still starting a cached image.


Step 4: Test everything locally first

Before adding Tailscale or ChatGPT, I verified the API entirely inside my LAN.

Health check:

curl http://TRUENAS_IP:8765/health
        

Expected response:

{
          "ok": true
        }
        

A protected endpoint without a key should fail:

curl http://TRUENAS_IP:8765/notes/list
        

Expected:

401 Unauthorized
        

Then test an authenticated request:

curl   -H "Authorization: Bearer YOUR_API_KEY"   "http://TRUENAS_IP:8765/notes/list?limit=10"
        

Finally, create a note:

curl   -X POST   "http://TRUENAS_IP:8765/notes"   -H "Authorization: Bearer YOUR_API_KEY"   -H "Content-Type: application/json"   -d '{
            "title": "API Test",
            "folder": "Inbox",
            "content": "# API Test\n\nCreated through FastAPI.",
            "tags": ["test", "fastapi"]
          }'
        

That should create:

/mnt/Apps/AppsData/ObsidianVault/Inbox/API Test.md
        

and Syncthing should later copy it into the Windows vault.

At this point I know the storage, permissions, API, and synchronization are working before the public-access layer is involved.


Step 5: Expose only the API with Tailscale Funnel

ChatGPT cannot call a private LAN address such as:

http://TRUENAS_IP:8765
        

I also did not want to forward a router port, expose my NAS administration interface, depend on a static public IP, or publish unrelated TrueNAS services.

I already use Tailscale, so I installed it as a TrueNAS application and used Funnel to proxy the API listening locally on port 8765 to a public HTTPS endpoint:

tailscale funnel --bg 8765
        

That gives the service a public HTTPS endpoint under the machine's Tailscale hostname:

https://YOUR-TAILSCALE-HOST.ts.net
        

The request path is now:

ChatGPT
           ↓
        HTTPS
           ↓
        Tailscale Funnel
           ↓
        local API target :8765
           ↓
        FastAPI container :8000
           ↓
        /vault
        

No router port forwarding is required.

One important security detail

Tailscale Funnel makes the URL publicly reachable. HTTPS protects the connection, but knowing the URL must not be enough to read or write the vault.

The real authorization boundary is the Bearer API key.

That is why the public API surface is deliberately small:

  • protected note operations require the key,
  • the key stays outside source code,
  • only Markdown files are accessible,
  • paths cannot escape the vault,
  • there is no delete endpoint,
  • FastAPI /docs, /redoc, and /openapi.json are disabled,
  • request/file sizes are bounded.

Only /health and /privacy are unauthenticated, and /health returns nothing more interesting than:

{
          "ok": true
        }
        

If I publish a GPT that uses Actions, it needs a valid Privacy Policy URL. I use the bridge itself to provide that URL:

https://YOUR-TAILSCALE-HOST.ts.net/privacy
        

Before connecting ChatGPT, I test the Funnel directly:

curl https://YOUR-TAILSCALE-HOST.ts.net/health
        

and then:

curl   -H "Authorization: Bearer YOUR_API_KEY"   https://YOUR-TAILSCALE-HOST.ts.net/notes/list
        

If those work, the network path is ready.


Step 6: Connect the API to a Custom GPT Action

Inside the Custom GPT configuration, I create an Action using API Key authentication and configure the same secret stored in the server's .env.

The Action exposes:

createNote     POST   /notes
        appendNote     POST   /notes/append
        readNote       GET    /notes/read
        searchNotes    POST   /notes/search
        listNotes      GET    /notes/list
        

This is the OpenAPI schema I use. Replace https://YOUR-TAILSCALE-HOST.ts.net with your actual Funnel hostname.

Complete OpenAPI schema

Show complete OpenAPI schema
openapi: 3.1.0

        info:
          title: Obsidian Second Brain API
          version: 1.0.0
          description: >
            Read, search, create, and append Markdown notes
            in a private Obsidian vault.

        servers:
          - url: https://YOUR-TAILSCALE-HOST.ts.net
            description: Public HTTPS URL of the Obsidian bridge.

        security:
          - bearerAuth: []

        paths:

          /notes:
            post:
              operationId: createNote
              summary: Create a new Obsidian note
              description: >
                Use when the user explicitly asks to save,
                store, capture, or create a new note.
                Do not use if a same-topic note should be
                updated instead.

              requestBody:
                required: true

                content:
                  application/json:

                    schema:
                      type: object

                      required:
                        - title
                        - content

                      properties:

                        title:
                          type: string
                          description: >
                            Concise human-readable title
                            without .md

                        folder:
                          type: string
                          default: Inbox
                          description: >
                            Vault-relative folder such as
                            Oracle APEX/eRecept

                        content:
                          type: string
                          description: >
                            Markdown body without YAML
                            front matter or duplicated H1 title

                        tags:
                          type: array

                          items:
                            type: string

                          description: >
                            Short Obsidian tags without
                            leading #

              responses:

                '200':
                  description: Note created or unchanged

                '409':
                  description: >
                    Note with the same filename already exists


          /notes/append:
            post:

              operationId: appendNote

              summary: Append content to an existing note

              description: >
                Use when the user wants to add information
                to a specific existing note.
                Search or read first if the exact path
                is unknown.

              requestBody:
                required: true

                content:
                  application/json:

                    schema:
                      type: object

                      required:
                        - path
                        - content

                      properties:

                        path:
                          type: string
                          description: >
                            Vault-relative Markdown path

                        content:
                          type: string
                          description: >
                            Markdown to append

                        dedupe_key:
                          type: string
                          description: >
                            Optional stable identifier for
                            this append to prevent duplicate
                            retries

              responses:

                '200':
                  description: >
                    Content appended or already applied


          /notes/read:
            get:

              operationId: readNote

              summary: Read one note

              description: >
                Use when the exact vault-relative note path
                is already known.

              parameters:

                - in: query
                  name: path
                  required: true

                  schema:
                    type: string

                  description: >
                    Vault-relative .md path

              responses:

                '200':
                  description: Note content


          /notes/search:
            post:

              operationId: searchNotes

              summary: Search notes by text

              description: >
                Use to find an existing note before reading
                or appending when its exact path is unknown.

              requestBody:
                required: true

                content:
                  application/json:

                    schema:
                      type: object

                      required:
                        - query

                      properties:

                        query:
                          type: string

                        folder:
                          type: string
                          default: ''

                        limit:
                          type: integer
                          minimum: 1
                          maximum: 50
                          default: 10

              responses:

                '200':
                  description: >
                    Matching note paths and snippets


          /notes/list:
            get:

              operationId: listNotes

              summary: List notes

              description: >
                Use to browse recent notes in the whole
                vault or a folder.

              parameters:

                - in: query
                  name: folder
                  required: false

                  schema:
                    type: string
                    default: ''

                - in: query
                  name: limit
                  required: false

                  schema:
                    type: integer
                    minimum: 1
                    maximum: 200
                    default: 50

              responses:

                '200':
                  description: Matching note paths


        components:

          securitySchemes:

            bearerAuth:
              type: http
              scheme: bearer
        

Once imported, I test each operation from the GPT Action configuration before relying on it in normal conversations.

What needs to be configured in Obsidian?

Almost nothing.

There is:

  • no Obsidian REST plugin,
  • no Local REST API plugin,
  • no custom Obsidian plugin,
  • no API key inside Obsidian,
  • no listener running inside the desktop application.

FastAPI writes normal Markdown files into the NAS-side vault. Syncthing copies those files to Windows, and Obsidian sees them like any other note.

Obsidian does not even need to be open when the note is created.


Step 7: Make the GPT build a knowledge base, not a pile of summaries

Getting Markdown into the vault solved the original problem, but it also exposed a more interesting possibility.

Instead of blindly creating a new note for every transcript, the GPT can inspect the vault before it writes.

My workflow became:

Understand source
              ↓
        Extract useful concepts
              ↓
        List/search existing notes
              ↓
        Reuse existing concepts
              ↓
        Create structured note
              ↓
        Create only useful missing concept notes
        

This turns the system from a one-way note exporter into a small knowledge-management agent.

For example, a transcript about habit formation might contain:

habit loop
        dopamine
        reward prediction error
        reinforcement learning
        motivation
        

The GPT first checks whether those concepts already exist. If Reinforcement Learning.md is already in the vault, it links to:

[[Reinforcement Learning]]
        

instead of creating another version with a slightly different title.

If an important concept does not exist and the source contains enough information to define it properly, the GPT can create a small concept note. My real vault uses the folder name Pojmy/ ("Concepts"), so I keep that literal filesystem path in the prompt.

The instructions below are written in English for readability, but they deliberately tell the GPT to create the actual Obsidian notes in Slovak, matching the way I use my vault.

Complete Custom GPT instructions

Show complete Custom GPT instructions
# Role

        Create concise, accurate, well-structured Obsidian notes in Slovak from transcripts, articles, long-form text, or files provided by the user. All generated note content and visible section headings must be in Slovak.

        Use the available Obsidian Actions to save the finished note directly to the vault. The goal is not just to produce isolated summaries, but to gradually build a connected knowledge base by reusing existing notes and creating meaningful backlinks.


        # Automatic workflow

        When the user provides source material to process, asks for an Obsidian note, or says to save something to Obsidian:

        1. Analyze the complete source material.
        2. Identify the main topic, important concepts, and possible links to existing notes.
        3. Create a concise, descriptive title.
        4. Build the Markdown note using the template below.
        5. Choose the most appropriate vault folder. If uncertain, use `Inbox`.
        6. Create 3–8 relevant tags.
        7. Check the vault for existing backlink candidates before creating new concept notes.
        8. Reuse existing notes whenever they cover the same or a very similar concept.
        9. Add the selected backlinks naturally to the main note.
        10. Call `createNote` to save the main note.
        11. Send a clean title without Markdown formatting or `.md` in `title`.
        12. Send only the Markdown body in `content`. Do not include YAML front matter; the server creates it automatically.
        13. Create missing concept notes only when they have clear long-term value and the source provides enough information to define them.
        14. After a successful write, reply only with a short confirmation and the created file path.

        Example:

        Saved to Obsidian:
        `Learning/How Dopamine Shapes Motivation.md`

        New concept notes:
        `[[Reward Prediction Error]]`, `[[Orthogonality]]`

        If the write fails, never claim that the note was saved. Return the error instead.

        If the user explicitly asks to generate a note without saving it, do not call `createNote` and do not create concept notes.

        If a transcript is provided as the main input without further instructions, process it and save it automatically.


        # Working with existing notes

        Use the actions as follows:

        - find an existing note → `searchNotes`
        - read a known note → `readNote`
        - add information to an existing note → `appendNote`
        - browse existing notes → `listNotes`

        Before creating a note for a topic that may already exist, check the vault first. Prefer updating or linking to an existing note over creating a duplicate.


        # Backlinks and concept notes

        For each main note, identify roughly 4–8 concepts that are valuable enough to exist as independent knowledge-base nodes.

        Check them efficiently:

        - use `listNotes` first when comparing candidate concepts against known titles,
        - use `searchNotes` when the title is ambiguous or the concept may exist under another name,
        - never call `searchNotes` with an empty query,
        - use the existing note title as the backlink target when a relevant note already exists.

        Do not create duplicate, synonymous, or slightly renamed versions of the same concept.

        If a useful concept does not exist, create a short note in `Pojmy/` using only information supported by the current source:

        # {{Concept name}}

        ## Definition

        Explain the concept clearly enough to understand it without the original source.

        ## Context

        Explain how the concept relates to the current topic.

        ## Related concepts

        - [[Related concept]]

        Do not create a concept note when the source does not contain enough information for a useful definition.


        # Main note rules

        - Follow the section structure below.
        - Write for long-term use, not as a disposable summary.
        - Use clear Markdown and concise bullet points where appropriate.
        - Make the note understandable months or years later without the original source.
        - Use backlinks naturally and consistently.
        - Leave a section empty when the source contains nothing useful for it.
        - Do not repeat the same idea across multiple sections unless the new section adds a different interpretation or practical consequence.
        - Prefer information density over length.

        Recommended size:

        - Summary: 2–4 paragraphs
        - Key ideas: 5–10 bullets
        - Important notes: 5–10 bullets
        - Insights: 3–6 bullets
        - Backlinks: 5–12
        - Action steps: maximum 3
        - Quotes: maximum 5

        These are guidelines, not hard limits.


        # Main note template

        Use the structure below, but render all visible headings and note content in Slovak.

        # 🎯 **Title:** {{Title / topic}}

        ## 📝 **Summary**

        A concise explanation of the main idea and the most important context.

        ## 🔑 **Key Ideas**

        - Key point 1
        - Key point 2
        - Key point 3

        ## 📚 **Important Notes**

        - facts, arguments, mechanisms, or examples worth remembering

        ## 🧠 **Insights**

        - useful interpretations
        - practical implications for decisions, work, learning, or behavior

        ## 🗂️ **Backlinks**

        - [[Related Topic 1]]
        - [[Related Topic 2]]

        ## 🗒️ **Action Steps**

        - 1–3 practical actions when the source supports them

        ## 📎 **Quotes or Key Lines**

        > important wording, highlights, or direct quotations

        ---
        

What the finished workflow feels like

After all that infrastructure, the daily interaction is intentionally boring.

I give the GPT a transcript and say:

Process this transcript and save it to Obsidian.

Behind that sentence, it can:

  1. analyze the source,
  2. extract important ideas,
  3. identify backlink candidates,
  4. inspect existing notes,
  5. search ambiguous concepts,
  6. generate the structured Markdown,
  7. save the main note,
  8. create useful missing concept notes,
  9. return the paths that were written.

The response can be as small as:

Saved to Obsidian:
        Learning/How Dopamine Shapes Motivation.md

        New concept notes:
        [[Reward Prediction Error]], [[Gratification]]
        

A short time later, Syncthing brings the files into my normal desktop vault.

That is the part I like most: the complexity disappears during actual use.


Troubleshooting the problems I actually hit

These are the checks I would do first if I rebuilt the project.

Syncthing stays out of sync

If the log contains:

chmod ... operation not permitted
        

enable Ignore Permissions for the TrueNAS-side Syncthing folder.

FastAPI container keeps restarting

If the logs contain:

Permission denied: /app/app/main.py
        

make sure the Dockerfile sets ownership and readable permissions:

COPY --chown=568:568 app ./app

        RUN find /app -type d     -exec chmod 755 {} \; &&     find /app -type f     -exec chmod 644 {} \;
        

Then force a clean rebuild.

/health works but note operations return 401

The API key in the Custom GPT Action and the server .env do not match.

The API works locally but not through ChatGPT

Test the Funnel directly:

curl https://YOUR-TAILSCALE-HOST.ts.net/health
        

If that fails, debug the path between Tailscale Funnel and the API before touching the GPT Action.

The GPT Action shows no operations

Validate the OpenAPI YAML and make sure each endpoint has a unique operationId:

createNote
        appendNote
        readNote
        searchNotes
        listNotes
        

The GPT creates duplicate concept notes

This is usually an instruction problem rather than an API problem.

The workflow should explicitly be:

list existing notes
        → search similar concepts
        → reuse existing note
        → create only when necessary
        

Conclusion

I started with a clipboard problem: ChatGPT could already produce the note I wanted, but I still had to move it into Obsidian myself.

The useful realization was that I never really needed to integrate with Obsidian. I only needed a narrow, authenticated way to work with the Markdown files that Obsidian already understands. FastAPI provides that bridge, TrueNAS gives it a permanent place to run, Tailscale Funnel makes only that small API reachable, and Syncthing keeps the desktop vault local.

That separation is what makes the setup worth keeping. The automation can inspect existing notes, reuse concepts, create backlinks, and save new material, but the knowledge base itself is not trapped inside the automation. The vault is still plain text. If I replace ChatGPT, FastAPI, Tailscale, Syncthing, or even Obsidian later, the notes remain mine.

And after the infrastructure is configured, none of it matters during normal use. I can reduce the whole system to one instruction:

Process this transcript and save it to Obsidian.

For me, that is the best kind of self-hosted automation: the implementation can be complex, but the workflow should disappear.