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
- Step 1: Sync the vault with Syncthing
- Step 2: Build a small REST bridge
- Step 3: Run the API on TrueNAS
- Step 4: Test everything locally
- Step 5: Expose only the API with Tailscale Funnel
- Step 6: Connect the Custom GPT Action
- Step 7: Build a connected knowledge base
- Troubleshooting
- Conclusion
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/passwdcannot escape the configured vault. - Markdown only. The service rejects non-
.mdtargets 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
unchangedinstead of creating another note. - Idempotent append behavior. An optional
dedupe_keyprevents 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.jsonare 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:
- analyze the source,
- extract important ideas,
- identify backlink candidates,
- inspect existing notes,
- search ambiguous concepts,
- generate the structured Markdown,
- save the main note,
- create useful missing concept notes,
- 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.