node:24-bookworm-slim image with git for the read-only vault endpoints, non-root user with system-wide git safe.directory (mounted vaults are usually owned by a host uid), /config + /data + /vaults mount conventions baked in via BRAINDUMP_CONFIG/BRAINDUMP_DB, and an /api/health healthcheck. README gains a Deployment section (mounts, claude-sdk skill/auth requirements in-container, compose example, reverse-proxy SSE buffering notes). dbPath in the config file now defaults to ./braindump.sqlite instead of being required — it was validated before the BRAINDUMP_DB env override was applied, so the image's baked-in db path couldn't rescue a config that omitted it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7.8 KiB
braindump
A headless server that runs brain-dump note-taking sessions into an Obsidian-style vault, exposing a web API meant to be consumed by a web UI or Obsidian plugin. An LLM agent interviews you conversationally and writes linked markdown notes into the vault as the conversation progresses, following the brain-dump skill (incremental writes, 1–3 questions per message, no extrapolation, TODO markers, projects/<name>/ layout with wikilinks).
Two interchangeable LLM backends, selected per session:
claude-sdk— the Claude Agent SDK. Gets file tools, skill loading (~/.claude/skills/brain-dump), and session resume from the SDK. Uses your Anthropic credentials.openai-compat— any OpenAI-compatible chat-completions endpoint (openrouter, vllm, ollama). A built-in agent loop supplies vault-scoped file tools and injects the skill into the system prompt.
Requirements
- Node ≥ 22 (uses
node:sqlite) - For
claude-sdk: Anthropic credentials (the SDK inherits Claude Code auth orANTHROPIC_API_KEY)
Setup
npm install
cp config.example.jsonc config.jsonc # edit: vaults, backends, models
npm run web # boots, auto-migrates the db, listens
Config is JSONC; path from BRAINDUMP_CONFIG (default ./config.jsonc). "${ENV_VAR}" string values are resolved from the environment; ~ is expanded in paths. Only vaults listed in vaults are writable — that allow-list is the write fence.
Note: an exported
PORTenv var overrides the config file'sport(watch out under code-server, which exportsPORTglobally).BRAINDUMP_DBoverridesdbPath.
There is no auth — put it behind an authenticating reverse proxy. CORS origins are configurable (app://obsidian.md is in the example for Obsidian plugins).
Deployment (Docker)
docker build -t braindump .
The image expects three mounts (paths inside the container are up to you; these match the defaults baked into the image via BRAINDUMP_CONFIG=/config/config.jsonc and BRAINDUMP_DB=/data/braindump.sqlite):
| Mount | Purpose |
|---|---|
/config/config.jsonc |
your config — point vaults[].path at the container-side vault paths |
/data |
persistent volume for the SQLite db |
/vaults/... |
your vault(s), read-write |
Backend-specific requirements:
claude-sdk: passANTHROPIC_API_KEY, and mount the brain-dump skill into the container user's home so the SDK's skill loading finds it:-v ~/.claude/skills/brain-dump:/home/braindump/.claude/skills/brain-dump:ro.openai-compat: mount the skill file whereverskillPathin your config points (e.g./config/SKILL.md), and pass any${ENV_VAR}referenced by the config (e.g.OPENROUTER_API_KEY).
Example compose.yaml:
services:
braindump:
build: . # or image: gitea.codinget.me/codinget/braindump
ports: ["3000:3000"]
environment:
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
volumes:
- ./config.jsonc:/config/config.jsonc:ro
- braindump-data:/data
- ~/git/brain:/vaults/brain
- ~/.claude/skills/brain-dump:/home/braindump/.claude/skills/brain-dump:ro
volumes:
braindump-data:
with the corresponding config using container paths: "vaults": [{ "name": "brain", "path": "/vaults/brain" }] and "skillPath": "/home/braindump/.claude/skills/brain-dump/SKILL.md".
Notes:
- The container runs as a non-root
braindumpuser; gitsafe.directoryis pre-configured system-wide so the read-only git endpoints work on vaults owned by your host uid. Vault writes need the mounted vault to be writable by the container user (chmod/chownor a matching uid viauser:). - The healthcheck and config default both use port 3000; if you change the port, change both (
PORTenv works too, and overrides the config). - Reverse proxy + SSE: disable response buffering for
/api/sessions/*/messages(nginx:proxy_buffering off;, or honor theX-Accel-Buffering: nopattern) and raise/disable the proxy read timeout for long turns — heartbeat comments are sent every ~15s to keep the connection alive. This is also where your auth goes.
API
Base path /api. Errors are {"ok":false,"error":"<code>"}.
| Method | Path | Description |
|---|---|---|
| GET | /health |
liveness |
| GET | /vaults |
configured vaults |
| POST | /sessions |
create session — {vault, topic, backend?, model?} |
| GET | /sessions[?status=] |
list sessions |
| GET | /sessions/:id |
session + transcript |
| PATCH | /sessions/:id |
update {topic?, status?} |
| DELETE | /sessions/:id[?hard=true] |
archive (soft) or delete row; never touches vault files |
| POST | /sessions/:id/messages |
send a user message — response is an SSE stream for the turn |
| GET | /sessions/:id/file?path= |
read a vault file (path-validated) |
| GET | /sessions/:id/git/status |
git status --porcelain, parsed |
| GET | /sessions/:id/git/diff[?path=] |
raw diff (read-only; commits are always left to you) |
The turn stream
POST /sessions/:id/messages with {"text": "..."} responds with text/event-stream:
event: text data: {"delta":"What's the goal of the project?"}
event: tool_start data: {"tool":"write_file","path":"projects/x/x.md"}
event: file_write data: {"path":"projects/x/x.md","bytes":412}
event: tool_end data: {"tool":"write_file","ok":true}
event: assistant_message data: {"text":"...full assistant text..."}
event: turn_complete data: {"turnCount":1}
Consume with fetch + a ReadableStream reader (or curl -N). One turn at a time per session — a concurrent POST gets 409 turn_in_progress. Closing the connection aborts the turn; files already written stay (writes are incremental by design). Heartbeat comments keep proxies from timing out.
Curl walkthrough
# create a session
SID=$(curl -s localhost:3000/api/sessions -H 'content-type: application/json' \
-d '{"vault":"brain","topic":"my-project","backend":"claude-sdk"}' | jq -r .session.id)
# first turn — watch text stream and files land
curl -N localhost:3000/api/sessions/$SID/messages -H 'content-type: application/json' \
-d '{"text":"Brain dump my-project: a CLI tool that does X, written in Go, status: early prototype."}'
# what changed in the vault?
curl -s localhost:3000/api/sessions/$SID/git/status | jq
curl -s "localhost:3000/api/sessions/$SID/file?path=projects/my-project/my-project.md" | jq -r .content
# keep going — the agent asks 1-3 questions per turn and extends the notes
curl -N localhost:3000/api/sessions/$SID/messages -H 'content-type: application/json' \
-d '{"text":"It also has a TUI mode. TODO on my side: pick a license."}'
# review + commit yourself when happy (braindump never commits)
git -C ~/git/brain status
Development
npm run typecheck
npm test # pathSafety + Db unit tests
PORT=11500 npx tsx scripts/mock-openai.ts # deterministic mock chat-completions server
scripts/mock-openai.ts scripts a full tool-calling session (including a vault-escape probe when the user message contains ESCAPE), so the whole openai-compat path can be tested end-to-end without a real model — point backends.openai-compat.baseUrl at it.
Layout
src/agent/—AgentBackendinterface + the two adapters (claudeSdk/,openaiCompat/) and the stubsrc/vault/— vault allow-list resolution,pathSafety(symlink-aware escape prevention), read-only git helperssrc/webapi/— Koa router, SSE plumbing, middlewaresrc/db/—node:sqlitewrapper + migrations (sessions, messages, turn_events)
Backends own message persistence: openai-compat stores its full replayable history in SQLite; claude-sdk stores display rows and resumes via the SDK's own transcript (sdk_session_id).