Node.js/TypeScript server with MCP endpoint for AI assistant integration, Discord bot for thought capture and slash commands, PostgreSQL + pgvector for semantic search, and OpenRouter for embeddings/metadata extraction. Dockerized for deployment behind Caddy. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
377 lines
32 KiB
Markdown
377 lines
32 KiB
Markdown
Open Brain — Self-Hosted Implementation Plan
|
||
|
||
Context
|
||
|
||
Build a self-hosted personal knowledge infrastructure ("Open Brain") on a DigitalOcean VPS. Based on the guide at promptkit.natebjones.com, adapted to replace Supabase with self-hosted components running in Docker behind Caddy.
|
||
|
||
What it does: A single PostgreSQL database that any AI assistant can read from and write to via MCP (Model Context Protocol). Thoughts can also be captured and queried through a Discord bot. Every thought gets a vector embedding for
|
||
semantic search and structured metadata (type, topics, people, action items) extracted by an LLM.
|
||
|
||
Two input paths:
|
||
- Discord #capture channel — type a thought, bot ingests it
|
||
- MCP capture_thought tool — any connected AI can save a thought directly
|
||
|
||
Three output paths:
|
||
- MCP tools — AI assistants call search_thoughts, list_thoughts, thought_stats
|
||
- Discord slash commands — /brain search, /brain recent, /brain stats
|
||
- Direct SQL via SSH — admin/debug access
|
||
|
||
---
|
||
Decisions
|
||
|
||
┌──────────────────┬──────────────────────────────────────────────────────────────────────┐
|
||
│ Decision │ Choice │
|
||
├──────────────────┼──────────────────────────────────────────────────────────────────────┤
|
||
│ Database │ Existing PostgreSQL on VPS + pgvector extension │
|
||
├──────────────────┼──────────────────────────────────────────────────────────────────────┤
|
||
│ Schema │ Includes user_id column (future-proofing for multi-user) │
|
||
├──────────────────┼──────────────────────────────────────────────────────────────────────┤
|
||
│ Runtime │ Node.js / TypeScript │
|
||
├──────────────────┼──────────────────────────────────────────────────────────────────────┤
|
||
│ Deployment │ Dockerized (Dockerfile + docker-compose.yml) │
|
||
├──────────────────┼──────────────────────────────────────────────────────────────────────┤
|
||
│ Messaging │ Discord bot (new bot, existing server) with capture + query commands │
|
||
├──────────────────┼──────────────────────────────────────────────────────────────────────┤
|
||
│ AI gateway │ OpenRouter (text-embedding-3-small + gpt-4o-mini) │
|
||
├──────────────────┼──────────────────────────────────────────────────────────────────────┤
|
||
│ Routing │ Subdomain brain.option.design via host-level Caddy │
|
||
├──────────────────┼──────────────────────────────────────────────────────────────────────┤
|
||
│ Security │ 64-char hex key on MCP endpoint + Caddy rate limiting (30 req/min) │
|
||
├──────────────────┼──────────────────────────────────────────────────────────────────────┤
|
||
│ MCP write access │ Enabled — capture_thought tool included │
|
||
├──────────────────┼──────────────────────────────────────────────────────────────────────┤
|
||
│ Backups │ Not included — user will handle separately │
|
||
├──────────────────┼──────────────────────────────────────────────────────────────────────┤
|
||
│ Repo │ https://git.option.design/will/open-brain.git │
|
||
└──────────────────┴──────────────────────────────────────────────────────────────────────┘
|
||
|
||
---
|
||
Architecture
|
||
|
||
Internet (HTTPS)
|
||
|
|
||
Caddy (host-level, auto-TLS)
|
||
rate_limit: 30 req/min per IP
|
||
|
|
||
brain.option.design → localhost:3100
|
||
|
|
||
┌─────────────────────────────┐
|
||
│ Docker: open-brain │
|
||
│ │
|
||
│ Hono HTTP Server (:3100) │
|
||
│ ├── POST /mcp ─┼──→ AI clients (Claude Desktop,
|
||
│ │ (MCP StreamableHTTP) │ ChatGPT, Claude Code, Cursor)
|
||
│ │ Auth: x-brain-key │ call this endpoint over HTTPS
|
||
│ │ │
|
||
│ └── Discord.js bot ─┼──→ Discord #capture channel
|
||
│ (outbound WebSocket) │ (bot connects OUT to Discord,
|
||
│ ├── message capture │ no inbound traffic needed)
|
||
│ └── slash commands │
|
||
│ /brain search │
|
||
│ /brain recent │
|
||
│ /brain stats │
|
||
│ │
|
||
│ Shared ingest pipeline │
|
||
│ ├── getEmbedding() ─┼──→ OpenRouter API
|
||
│ └── extractMetadata() ─┼──→ OpenRouter API
|
||
└──────────────┬───────────────┘
|
||
│
|
||
PostgreSQL + pgvector (host)
|
||
└── brain database
|
||
└── thoughts table
|
||
|
||
How each actor accesses the system
|
||
|
||
┌───────────────────────────────────┬─────────────────────────────────┬──────────────────────────────────────────────┬──────────────────────────┐
|
||
│ Actor │ Path │ Auth │ Direction │
|
||
├───────────────────────────────────┼─────────────────────────────────┼──────────────────────────────────────────────┼──────────────────────────┤
|
||
│ Claude Desktop / ChatGPT / Cursor │ HTTPS → Caddy → :3100/mcp │ MCP key (configured once in client settings) │ Client calls your server │
|
||
├───────────────────────────────────┼─────────────────────────────────┼──────────────────────────────────────────────┼──────────────────────────┤
|
||
│ Claude Code │ Same as above │ MCP key (configured via claude mcp add) │ Client calls your server │
|
||
├───────────────────────────────────┼─────────────────────────────────┼──────────────────────────────────────────────┼──────────────────────────┤
|
||
│ Discord (you typing in #capture) │ Discord servers → bot WebSocket │ Discord login │ Bot connects outward │
|
||
├───────────────────────────────────┼─────────────────────────────────┼──────────────────────────────────────────────┼──────────────────────────┤
|
||
│ Discord slash commands │ Same WebSocket │ Discord login │ Bot connects outward │
|
||
├───────────────────────────────────┼─────────────────────────────────┼──────────────────────────────────────────────┼──────────────────────────┤
|
||
│ You via SSH │ SSH → psql │ SSH key (existing) │ Direct DB access │
|
||
└───────────────────────────────────┴─────────────────────────────────┴──────────────────────────────────────────────┴──────────────────────────┘
|
||
|
||
Note: The MCP endpoint at brain.option.design is public-facing (reachable from the internet) because AI clients like Claude Desktop and ChatGPT call it from their own servers. It is protected by a 64-character hex key over HTTPS and
|
||
rate-limited at 30 requests/minute per IP. Without the correct key, all requests are rejected with 401.
|
||
|
||
---
|
||
Implementation Steps
|
||
|
||
Step 1: PostgreSQL + pgvector
|
||
|
||
Verify pgvector is installed on the existing PostgreSQL; install if needed. Create database, user, and schema.
|
||
|
||
File: sql/schema.sql
|
||
|
||
CREATE EXTENSION IF NOT EXISTS vector;
|
||
|
||
CREATE TABLE thoughts (
|
||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
user_id TEXT NOT NULL DEFAULT 'default',
|
||
content TEXT NOT NULL,
|
||
embedding VECTOR(1536),
|
||
metadata JSONB DEFAULT '{}'::jsonb,
|
||
source TEXT NOT NULL DEFAULT 'mcp', -- 'discord' or 'mcp'
|
||
created_at TIMESTAMPTZ DEFAULT now(),
|
||
updated_at TIMESTAMPTZ DEFAULT now()
|
||
);
|
||
|
||
CREATE INDEX ON thoughts USING hnsw (embedding vector_cosine_ops);
|
||
CREATE INDEX ON thoughts USING gin (metadata);
|
||
CREATE INDEX ON thoughts (created_at DESC);
|
||
CREATE INDEX ON thoughts (user_id);
|
||
|
||
-- Auto-update trigger for updated_at
|
||
CREATE OR REPLACE FUNCTION update_updated_at() RETURNS TRIGGER AS $$
|
||
BEGIN NEW.updated_at = now(); RETURN NEW; END;
|
||
$$ LANGUAGE plpgsql;
|
||
|
||
CREATE TRIGGER thoughts_updated_at BEFORE UPDATE ON thoughts
|
||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||
|
||
-- Semantic search function
|
||
CREATE OR REPLACE FUNCTION match_thoughts(
|
||
query_embedding VECTOR(1536),
|
||
match_threshold FLOAT DEFAULT 0.7,
|
||
match_count INT DEFAULT 10,
|
||
filter JSONB DEFAULT '{}'::jsonb
|
||
) RETURNS TABLE (
|
||
id UUID, content TEXT, metadata JSONB,
|
||
similarity FLOAT, created_at TIMESTAMPTZ
|
||
) LANGUAGE plpgsql AS $$
|
||
BEGIN
|
||
RETURN QUERY
|
||
SELECT t.id, t.content, t.metadata,
|
||
1 - (t.embedding <=> query_embedding) AS similarity,
|
||
t.created_at
|
||
FROM thoughts t
|
||
WHERE 1 - (t.embedding <=> query_embedding) > match_threshold
|
||
ORDER BY t.embedding <=> query_embedding
|
||
LIMIT match_count;
|
||
END;
|
||
$$;
|
||
|
||
Deploy: SSH to VPS, run psql -d brain -f sql/schema.sql
|
||
|
||
Step 2: Node.js/TypeScript Project
|
||
|
||
Project structure:
|
||
open-brain/
|
||
├── Dockerfile
|
||
├── docker-compose.yml
|
||
├── package.json
|
||
├── tsconfig.json
|
||
├── .env.example
|
||
├── .gitignore
|
||
├── sql/
|
||
│ └── schema.sql
|
||
└── src/
|
||
├── index.ts — Hono server entry, starts HTTP + Discord bot
|
||
├── db.ts — pg client with pgvector, query helpers
|
||
├── ai.ts — OpenRouter: getEmbedding() + extractMetadata()
|
||
├── discord.ts — Discord.js bot: message capture + slash commands
|
||
├── mcp.ts — MCP server with 4 tool definitions
|
||
├── ingest.ts — Shared pipeline: embed → extract metadata → store
|
||
└── types.ts — TypeScript interfaces (Thought, Metadata)
|
||
|
||
Key dependencies:
|
||
- @modelcontextprotocol/sdk — MCP protocol server
|
||
- hono + @hono/node-server — HTTP framework on Node.js
|
||
- discord.js — Discord gateway bot
|
||
- pg + pgvector — PostgreSQL client with vector support
|
||
- zod — schema validation for MCP tool inputs
|
||
|
||
Step 3: Shared Ingest Pipeline (src/ingest.ts)
|
||
|
||
Both Discord and MCP capture_thought use the same pipeline:
|
||
|
||
Input text
|
||
→ getEmbedding(text) — OpenRouter text-embedding-3-small → 1536-dim vector
|
||
→ extractMetadata(text) — OpenRouter gpt-4o-mini → structured JSON
|
||
→ INSERT into thoughts table — content + embedding + metadata + source tag
|
||
→ Return confirmation — "Captured as [type] — [topics]"
|
||
|
||
Metadata schema extracted by LLM:
|
||
{
|
||
"type": "observation | task | idea | reference | person_note",
|
||
"topics": ["tag1", "tag2"],
|
||
"people": ["name1"],
|
||
"action_items": ["todo1"],
|
||
"dates_mentioned": ["YYYY-MM-DD"]
|
||
}
|
||
|
||
Step 4: MCP Server (src/mcp.ts)
|
||
|
||
Exposed at POST /mcp via StreamableHTTP transport. Authenticated with x-brain-key header or ?key= query param.
|
||
|
||
┌─────────────────┬───────────────────────────────────────────────────────────────────────────────┬────────────────────────────────────────────────────┐
|
||
│ Tool │ Input │ What it does │
|
||
├─────────────────┼───────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────┤
|
||
│ search_thoughts │ query: string, limit?: number, threshold?: number │ Embed query → cosine similarity search in pgvector │
|
||
├─────────────────┼───────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────┤
|
||
│ list_thoughts │ limit?: number, type?: string, topic?: string, person?: string, days?: number │ Filter thoughts by metadata fields, return recent │
|
||
├─────────────────┼───────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────┤
|
||
│ thought_stats │ (none) │ Aggregate counts by type, topic, recent activity │
|
||
├─────────────────┼───────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────┤
|
||
│ capture_thought │ content: string │ Run full ingest pipeline, return confirmation │
|
||
└─────────────────┴───────────────────────────────────────────────────────────────────────────────┴────────────────────────────────────────────────────┘
|
||
|
||
Each tool is defined with Zod schemas and registered on the MCP server. The AI client discovers them automatically on connection.
|
||
|
||
Step 5: Discord Bot (src/discord.ts)
|
||
|
||
Setup (manual, one-time):
|
||
1. Create Discord Application at discord.com/developers/applications
|
||
2. Create Bot user, enable MESSAGE CONTENT privileged intent
|
||
3. Generate and copy bot token → goes in .env
|
||
4. Invite to server with permissions: Read/Send Messages, Read Message History, Use Slash Commands
|
||
5. Designate #capture channel, copy channel ID → goes in .env
|
||
|
||
Message capture behavior:
|
||
- Listens for messages in configured channel only
|
||
- Ignores bot messages and system messages
|
||
- Runs ingest pipeline on valid messages
|
||
- Replies with: Captured as [type] — [topic1], [topic2]
|
||
|
||
Slash commands:
|
||
- /brain search <query> — semantic search, returns top results
|
||
- /brain recent [count] — list recent thoughts
|
||
- /brain stats — show aggregate stats
|
||
|
||
Step 6: OpenRouter Integration (src/ai.ts)
|
||
|
||
Two API calls to https://openrouter.ai/api/v1/:
|
||
|
||
- getEmbedding(text): POST /embeddings, model text-embedding-3-small → returns 1536-dim float array
|
||
- extractMetadata(text): POST /chat/completions, model gpt-4o-mini, system prompt requesting JSON matching the metadata schema above
|
||
|
||
Estimated cost: ~$0.10–0.30/month at 20 thoughts/day.
|
||
|
||
Step 7: Docker Setup
|
||
|
||
Dockerfile — Multi-stage: Node.js 22 Alpine, install deps, compile TypeScript, run compiled JS in slim image.
|
||
|
||
docker-compose.yml:
|
||
services:
|
||
open-brain:
|
||
build: .
|
||
container_name: open-brain
|
||
restart: unless-stopped
|
||
ports:
|
||
- "3100:3100"
|
||
env_file: .env
|
||
extra_hosts:
|
||
- "host.docker.internal:host-gateway"
|
||
|
||
host.docker.internal lets the container reach the host's PostgreSQL. May require updating pg_hba.conf to allow connections from Docker's bridge subnet (typically 172.17.0.0/16).
|
||
|
||
Step 8: Caddy Configuration
|
||
|
||
Add to existing Caddyfile on the VPS:
|
||
|
||
brain.option.design {
|
||
rate_limit {remote.host} 30r/m
|
||
reverse_proxy localhost:3100
|
||
}
|
||
|
||
DNS: Add A record for brain.option.design → VPS IP. Caddy auto-provisions TLS certificate.
|
||
|
||
Note: Caddy's rate limiting requires the caddy-ratelimit plugin. If not available, we can implement rate limiting in the Node.js app instead (simpler, no Caddy plugin needed).
|
||
|
||
Step 9: AI Client Configuration
|
||
|
||
MCP URL: https://brain.option.design/mcp
|
||
|
||
┌────────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||
│ Client │ How to connect │
|
||
├────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||
│ Claude Desktop │ Settings → Connectors → Add remote MCP server → paste URL with ?key=ACCESS_KEY │
|
||
├────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||
│ Claude Code │ claude mcp add --transport http open-brain https://brain.option.design/mcp --header "x-brain-key: ACCESS_KEY" │
|
||
├────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||
│ ChatGPT │ Developer Mode → Apps & Connectors → Create → paste URL with ?key=ACCESS_KEY │
|
||
├────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||
│ Cursor │ Add to .cursor/mcp.json with URL and key header │
|
||
└────────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||
|
||
---
|
||
Environment Variables (.env)
|
||
|
||
# Database (host.docker.internal = host machine from inside Docker)
|
||
DATABASE_URL=postgresql://brain_user:password@host.docker.internal:5432/brain
|
||
|
||
# OpenRouter (AI gateway for embeddings + metadata extraction)
|
||
OPENROUTER_API_KEY=sk-or-...
|
||
|
||
# Discord bot
|
||
DISCORD_BOT_TOKEN=...
|
||
DISCORD_CAPTURE_CHANNEL_ID=...
|
||
|
||
# MCP endpoint authentication (generate with: openssl rand -hex 32)
|
||
MCP_ACCESS_KEY=...
|
||
|
||
# Server port
|
||
PORT=3100
|
||
|
||
---
|
||
Verification
|
||
|
||
1. Database: SSH to VPS → psql -d brain -c "SELECT count(*) FROM thoughts;" → returns 0
|
||
2. Discord capture: Send "Sarah is thinking about leaving her job to start consulting" in #capture → bot replies: Captured as person_note — career, consulting
|
||
3. Discord query: Type /brain search career → bot returns the thought about Sarah
|
||
4. Discord stats: Type /brain stats → bot shows 1 thought, type: person_note
|
||
5. MCP capture: In Claude Code, say "Remember that I prefer dark mode in all apps" → thought stored in DB
|
||
6. MCP search: Ask Claude "What do you know about my preferences?" → returns the dark mode thought
|
||
7. MCP list: Ask Claude "Show me recent thoughts" → returns both thoughts
|
||
8. Rate limit: Rapid-fire 30+ requests → subsequent requests get throttled
|
||
|
||
---
|
||
Current Status as of 3/6/2026 2:51:00 PM CST
|
||
---
|
||
|
||
● The project is fully scaffolded and compiles cleanly. Here's a summary of what was built:
|
||
Files Created
|
||
┌────────────────────┬─────────────────────────────────────────────────────────────────────┐
|
||
│ File │ Purpose │
|
||
├────────────────────┼─────────────────────────────────────────────────────────────────────┤
|
||
│ sql/schema.sql │ PostgreSQL schema with pgvector, match_thoughts() function, indexes │
|
||
├────────────────────┼─────────────────────────────────────────────────────────────────────┤
|
||
│ src/types.ts │ TypeScript interfaces for Thought, ThoughtMetadata, etc. │
|
||
├────────────────────┼─────────────────────────────────────────────────────────────────────┤
|
||
│ src/db.ts │ PostgreSQL client — insert, search, list, stats queries │
|
||
├────────────────────┼─────────────────────────────────────────────────────────────────────┤
|
||
│ src/ai.ts │ OpenRouter API — getEmbedding() + extractMetadata() │
|
||
├────────────────────┼─────────────────────────────────────────────────────────────────────┤
|
||
│ src/ingest.ts │ Shared ingest pipeline — embed + extract + store │
|
||
├────────────────────┼─────────────────────────────────────────────────────────────────────┤
|
||
│ src/mcp.ts │ MCP server with 4 tools (search, list, stats, capture) │
|
||
├────────────────────┼─────────────────────────────────────────────────────────────────────┤
|
||
│ src/discord.ts │ Discord bot — message capture + /brain slash commands │
|
||
├────────────────────┼─────────────────────────────────────────────────────────────────────┤
|
||
│ src/index.ts │ Hono HTTP server entry point, MCP auth, starts Discord bot │
|
||
├────────────────────┼─────────────────────────────────────────────────────────────────────┤
|
||
│ Dockerfile │ Multi-stage Docker build (Node 22 Alpine) │
|
||
├────────────────────┼─────────────────────────────────────────────────────────────────────┤
|
||
│ docker-compose.yml │ Container config with host.docker.internal for PostgreSQL │
|
||
├────────────────────┼─────────────────────────────────────────────────────────────────────┤
|
||
│ .env.example │ Template for environment variables │
|
||
├────────────────────┼─────────────────────────────────────────────────────────────────────┤
|
||
│ .gitignore │ Excludes node_modules, dist, .env │
|
||
└────────────────────┴─────────────────────────────────────────────────────────────────────┘
|
||
|
||
Next Steps (on your VPS)
|
||
|
||
1. Create Discord bot at discord.com/developers — get bot token, enable MESSAGE CONTENT intent, invite to server
|
||
2. Create .env from .env.example with your real credentials
|
||
3. Run schema on PostgreSQL: psql -d brain -f sql/schema.sql (ensure pgvector extension is available)
|
||
4. Add DNS A record for brain.option.design → VPS IP
|
||
5. Add Caddy block for brain.option.design → localhost:3100
|
||
6. Deploy: docker compose up -d --build
|
||
7. Connect AI clients using the MCP URL
|
||
|
||
Want me to commit this to the repo, or would you like to review anything first? |