Memex
Setup Guide

Your thoughts and context, owned by you — not your AI vendor. Store once, access from Claude, ChatGPT, Cursor, or whatever comes next. One brain, every tool.

Based on Nate Jones' Open Brain guide.

9 Steps
~20 min Setup time
$0.10–0.30 Per month
🗄️

Supabase Free

Postgres database with pgvector for storing thoughts, embeddings, and metadata.

🤖

OpenRouter

AI gateway. Generates embeddings (text-embedding-3-small) and extracts metadata (gpt-4o-mini).

🔌

MCP Server Hosted

A Supabase Edge Function exposing 4 tools: search, list, stats, and capture — accessible from any AI.

💬

AI Clients

Claude Code, Claude Desktop, ChatGPT, Cursor — any MCP-compatible client connects via a single URL.

1

Database Setup

Create your Supabase project and configure the vector database.

1

Create Your Supabase Project

Set up the free database that stores all your thoughts.

  1. Go to supabase.com and sign up (GitHub login is fastest)
  2. Click New Project in the dashboard
  3. Set the project name to memex
  4. Generate a strong Database password → save it in your password manager
  5. Pick your closest region
  6. Click Create new project and wait 1–2 minutes
  7. Find your Project ref in the dashboard URL (e.g. abcdefghijklmnop) → save it
ℹ️ The Project ref is in your dashboard URL: supabase.com/dashboard/project/YOUR_REF
2

Set Up the Database

Enable vector search and create the thoughts table + search function.

Enable the Vector Extension
  1. In Supabase, go to Database → Extensions
  2. Search for vector
  3. Flip pgvector ON
Run the Setup SQL
  1. Go to SQL Editor in the left sidebar
  2. Click New query
  3. Paste the entire block below and click Run
SQL
-- Create the thoughts table
create table thoughts (
  id uuid default gen_random_uuid() primary key,
  content text not null,
  embedding vector(1536),
  metadata jsonb default '{}'::jsonb,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- Indexes for fast search
create index on thoughts using hnsw (embedding vector_cosine_ops);
create index on thoughts using gin (metadata);
create index on thoughts (created_at desc);

-- Auto-update updated_at timestamp
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
    and (filter = '{}'::jsonb or t.metadata @> filter)
  order by t.embedding <=> query_embedding
  limit match_count;
end;
$$;

-- Lock down security (service role only)
alter table thoughts enable row level security;

create policy "Service role full access"
  on thoughts for all
  using (auth.role() = 'service_role');
Verify
  • Table Editor should show a thoughts table with columns: id, content, embedding, metadata, created_at, updated_at
  • Database → Functions should show match_thoughts
3

Save Your Supabase Credentials

You'll need these throughout setup.

  1. In Supabase, go to Settings (gear icon) → API
  2. Copy your Project URL → save it somewhere safe
  3. Copy the Secret key (also called "service_role key") → save it somewhere safe
⚠️ The secret key is hidden by default. Click Reveal before copying. Never share this key publicly — it bypasses all row-level security.
4

Get an OpenRouter API Key

OpenRouter powers both embeddings and metadata extraction.

  1. Go to openrouter.ai and sign up
  2. Navigate to openrouter.ai/keys
  3. Click Create Key, name it memex
  4. Copy the key immediately (it won't be shown again) → save it somewhere safe
  5. Go to Credits and add $5 — this will last months at typical usage
💡 Expected costs: text-embedding-3-small ~$0.02/million tokens · gpt-4o-mini ~$0.15/million input tokens. For 20 thoughts/day: roughly $0.10–0.30/month.
2

MCP Server

Deploy a hosted MCP server that gives any AI access to your brain.

5

Install the Supabase CLI & Link Your Project

You'll use this to deploy Edge Functions from your computer.

Step 5a — Open a terminal
  • Windows: Press Windows key + R, type powershell, press Enter. A blue/black window will open — this is PowerShell, your terminal.
  • Mac: Open Spotlight (Cmd + Space), type terminal, press Enter.
Step 5b — Install the Supabase CLI
  • Type or paste the command for your system below, then press Enter. It will download and install the Supabase CLI tool.
Windows — PowerShell (easiest, built into Windows 11)
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
ℹ️ winget is a package manager built into Windows 11 — no setup needed. After the install finishes, close PowerShell and reopen it before continuing.
macOS (Homebrew)
brew install supabase/tap/supabase
Step 5c — Verify it installed
Any terminal
supabase --version
  • You should see a version number like 2.x.x. If so, it worked.
Step 5d — Log in & link your project
  • Run these two commands one at a time. The first will open a browser to log in to Supabase. The second connects your terminal to your specific project.
Any terminal
supabase login
Any terminal — replace YOUR_PROJECT_REF
supabase link --project-ref YOUR_PROJECT_REF
ℹ️ Replace YOUR_PROJECT_REF with the value you saved in Step 1 — it's the string of letters/numbers in your Supabase dashboard URL after /project/.
6

Generate an Access Key

This key secures your MCP server — only clients with it can connect.

Mac / Linux
openssl rand -hex 32
Windows PowerShell
-join ((1..32) | ForEach-Object { '{0:x2}' -f (Get-Random -Maximum 256) })
  • Copy the 64-character output → save it somewhere safe as your MCP Access Key
Set Secrets in Supabase
Bash
supabase secrets set MCP_ACCESS_KEY=your-generated-key-here
supabase secrets set OPENROUTER_API_KEY=your-openrouter-key-here
ℹ️ SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are automatically available inside all Edge Functions — no need to set them manually.
7

Deploy the MCP Server

A Supabase Edge Function with 4 brain tools: search, list, stats, capture.

Create the Function
Bash
supabase functions new memex-mcp
Add Dependencies
  • Create the file supabase/functions/memex-mcp/deno.json with this content:
JSON — deno.json
{
  "imports": {
    "@hono/mcp": "npm:@hono/mcp@0.1.1",
    "@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/sdk@1.24.3",
    "hono": "npm:hono@4.9.2",
    "zod": "npm:zod@4.1.13",
    "@supabase/supabase-js": "npm:@supabase/supabase-js@2.47.10"
  }
}
Write the Server
  • Replace the entire contents of supabase/functions/memex-mcp/index.ts with the code below:
TypeScript — index.ts
import "jsr:@supabase/functions-js/edge-runtime.d.ts";

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPTransport } from "@hono/mcp";
import { Hono } from "hono";
import { z } from "zod";
import { createClient } from "@supabase/supabase-js";

const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const OPENROUTER_API_KEY = Deno.env.get("OPENROUTER_API_KEY")!;
const MCP_ACCESS_KEY = Deno.env.get("MCP_ACCESS_KEY")!;

const OPENROUTER_BASE = "https://openrouter.ai/api/v1";
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);

async function getEmbedding(text: string): Promise<number[]> {
  const r = await fetch(`${OPENROUTER_BASE}/embeddings`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${OPENROUTER_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ model: "openai/text-embedding-3-small", input: text }),
  });
  if (!r.ok) {
    const msg = await r.text().catch(() => "");
    throw new Error(`OpenRouter embeddings failed: ${r.status} ${msg}`);
  }
  const d = await r.json();
  return d.data[0].embedding;
}

async function extractMetadata(text: string): Promise<Record<string, unknown>> {
  const r = await fetch(`${OPENROUTER_BASE}/chat/completions`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${OPENROUTER_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model: "openai/gpt-4o-mini",
      response_format: { type: "json_object" },
      messages: [
        {
          role: "system",
          content: `Extract metadata from the user's captured thought. Return JSON with:
- "people": array of people mentioned (empty if none)
- "action_items": array of implied to-dos (empty if none)
- "dates_mentioned": array of dates YYYY-MM-DD (empty if none)
- "topics": array of 1-3 short topic tags (always at least one)
- "type": one of "observation", "task", "idea", "reference", "person_note"
Only extract what's explicitly there.`,
        },
        { role: "user", content: text },
      ],
    }),
  });
  const d = await r.json();
  try {
    return JSON.parse(d.choices[0].message.content);
  } catch {
    return { topics: ["uncategorized"], type: "observation" };
  }
}

// ── MCP Server ──────────────────────────────────────────────────────────────

const server = new McpServer({ name: "memex", version: "1.0.0" });

// Tool 1: Semantic Search
server.registerTool(
  "search_thoughts",
  {
    title: "Search Thoughts",
    description:
      "Search captured thoughts by meaning. Use when the user asks about a topic, person, or idea they've previously captured.",
    inputSchema: {
      query: z.string().describe("What to search for"),
      limit: z.number().optional().default(10),
      threshold: z.number().optional().default(0.5),
    },
  },
  async ({ query, limit, threshold }) => {
    try {
      const qEmb = await getEmbedding(query);
      const { data, error } = await supabase.rpc("match_thoughts", {
        query_embedding: qEmb,
        match_threshold: threshold,
        match_count: limit,
        filter: {},
      });
      if (error) return { content: [{ type: "text" as const, text: `Search error: ${error.message}` }], isError: true };
      if (!data || data.length === 0)
        return { content: [{ type: "text" as const, text: `No thoughts found matching "${query}".` }] };

      const results = data.map((t: any, i: number) => {
        const m = t.metadata || {};
        const parts = [
          `--- Result ${i + 1} (${(t.similarity * 100).toFixed(1)}% match) ---`,
          `Captured: ${new Date(t.created_at).toLocaleDateString()}`,
          `Type: ${m.type || "unknown"}`,
        ];
        if (Array.isArray(m.topics) && m.topics.length) parts.push(`Topics: ${m.topics.join(", ")}`);
        if (Array.isArray(m.people) && m.people.length) parts.push(`People: ${m.people.join(", ")}`);
        if (Array.isArray(m.action_items) && m.action_items.length) parts.push(`Actions: ${m.action_items.join("; ")}`);
        parts.push(`\n${t.content}`);
        return parts.join("\n");
      });
      return { content: [{ type: "text" as const, text: `Found ${data.length} thought(s):\n\n${results.join("\n\n")}` }] };
    } catch (err: unknown) {
      return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
    }
  }
);

// Tool 2: List Recent
server.registerTool(
  "list_thoughts",
  {
    title: "List Recent Thoughts",
    description: "List recently captured thoughts with optional filters by type, topic, person, or time range.",
    inputSchema: {
      limit: z.number().optional().default(10),
      type: z.string().optional().describe("Filter by type: observation, task, idea, reference, person_note"),
      topic: z.string().optional().describe("Filter by topic tag"),
      person: z.string().optional().describe("Filter by person mentioned"),
      days: z.number().optional().describe("Only thoughts from the last N days"),
    },
  },
  async ({ limit, type, topic, person, days }) => {
    try {
      let q = supabase.from("thoughts").select("content, metadata, created_at")
        .order("created_at", { ascending: false }).limit(limit);
      if (type) q = q.contains("metadata", { type });
      if (topic) q = q.contains("metadata", { topics: [topic] });
      if (person) q = q.contains("metadata", { people: [person] });
      if (days) {
        const since = new Date();
        since.setDate(since.getDate() - days);
        q = q.gte("created_at", since.toISOString());
      }
      const { data, error } = await q;
      if (error) return { content: [{ type: "text" as const, text: `Error: ${error.message}` }], isError: true };
      if (!data || !data.length) return { content: [{ type: "text" as const, text: "No thoughts found." }] };
      const results = data.map((t: any, i: number) => {
        const m = t.metadata || {};
        const tags = Array.isArray(m.topics) ? m.topics.join(", ") : "";
        return `${i + 1}. [${new Date(t.created_at).toLocaleDateString()}] (${m.type || "??"}${tags ? " - " + tags : ""})\n   ${t.content}`;
      });
      return { content: [{ type: "text" as const, text: `${data.length} recent thought(s):\n\n${results.join("\n\n")}` }] };
    } catch (err: unknown) {
      return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
    }
  }
);

// Tool 3: Stats
server.registerTool(
  "thought_stats",
  {
    title: "Thought Statistics",
    description: "Get a summary of all captured thoughts: totals, types, top topics, and people.",
    inputSchema: {},
  },
  async () => {
    try {
      const { count } = await supabase.from("thoughts").select("*", { count: "exact", head: true });
      const { data } = await supabase.from("thoughts").select("metadata, created_at").order("created_at", { ascending: false });
      const types: Record<string, number> = {};
      const topics: Record<string, number> = {};
      const people: Record<string, number> = {};
      for (const r of data || []) {
        const m = (r.metadata || {}) as Record<string, unknown>;
        if (m.type) types[m.type as string] = (types[m.type as string] || 0) + 1;
        if (Array.isArray(m.topics)) for (const t of m.topics) topics[t] = (topics[t] || 0) + 1;
        if (Array.isArray(m.people)) for (const p of m.people) people[p] = (people[p] || 0) + 1;
      }
      const sort = (o: Record<string, number>) => Object.entries(o).sort((a, b) => b[1] - a[1]).slice(0, 10);
      const lines = [
        `Total thoughts: ${count}`,
        data?.length
          ? `Date range: ${new Date(data[data.length - 1].created_at).toLocaleDateString()} → ${new Date(data[0].created_at).toLocaleDateString()}`
          : "Date range: N/A",
        "",
        "Types:",
        ...sort(types).map(([k, v]) => `  ${k}: ${v}`),
      ];
      if (Object.keys(topics).length) { lines.push("", "Top topics:"); for (const [k, v] of sort(topics)) lines.push(`  ${k}: ${v}`); }
      if (Object.keys(people).length) { lines.push("", "People mentioned:"); for (const [k, v] of sort(people)) lines.push(`  ${k}: ${v}`); }
      return { content: [{ type: "text" as const, text: lines.join("\n") }] };
    } catch (err: unknown) {
      return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
    }
  }
);

// Tool 4: Capture Thought
server.registerTool(
  "capture_thought",
  {
    title: "Capture Thought",
    description:
      "Save a new thought to the Memex. Generates an embedding and extracts metadata automatically. Use when the user wants to save notes, insights, decisions, or memories directly from any AI client.",
    inputSchema: {
      content: z.string().describe("The thought to capture — a clear, standalone statement that will make sense when retrieved later"),
    },
  },
  async ({ content }) => {
    try {
      const [embedding, metadata] = await Promise.all([getEmbedding(content), extractMetadata(content)]);
      const { error } = await supabase.from("thoughts").insert({ content, embedding, metadata: { ...metadata, source: "mcp" } });
      if (error) return { content: [{ type: "text" as const, text: `Failed to capture: ${error.message}` }], isError: true };
      const meta = metadata as Record<string, unknown>;
      let confirmation = `Captured as ${meta.type || "thought"}`;
      if (Array.isArray(meta.topics) && meta.topics.length) confirmation += ` — ${meta.topics.join(", ")}`;
      if (Array.isArray(meta.people) && meta.people.length) confirmation += ` | People: ${meta.people.join(", ")}`;
      if (Array.isArray(meta.action_items) && meta.action_items.length) confirmation += ` | Actions: ${meta.action_items.join("; ")}`;
      return { content: [{ type: "text" as const, text: confirmation }] };
    } catch (err: unknown) {
      return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
    }
  }
);

// ── Hono App with Auth ────────────────────────────────────────────────────────

const app = new Hono();

app.all("*", async (c) => {
  const provided = c.req.header("x-brain-key") || new URL(c.req.url).searchParams.get("key");
  if (!provided || provided !== MCP_ACCESS_KEY) {
    return c.json({ error: "Invalid or missing access key" }, 401);
  }
  const transport = new StreamableHTTPTransport();
  await server.connect(transport);
  return transport.handleRequest(c);
});

Deno.serve(app.fetch);
Deploy
Bash
supabase functions deploy memex-mcp --no-verify-jwt
✅ Your MCP server is live at:
https://YOUR_PROJECT_REF.supabase.co/functions/v1/memex-mcp
Your connection URL (with key):
https://YOUR_PROJECT_REF.supabase.co/functions/v1/memex-mcp?key=YOUR_ACCESS_KEY
3

Connect & Use

Add the MCP server to your AI clients.

8

Connect to Your AI

Pick any client below — you only need one to get started.

Claude Code (recommended)
Bash
claude mcp add --transport http memex \
  https://YOUR_PROJECT_REF.supabase.co/functions/v1/memex-mcp \
  --header "x-brain-key: YOUR_ACCESS_KEY"
Claude Desktop
  1. Open Claude Desktop → Settings → Connectors
  2. Click Add custom connector
  3. Name: Memex
  4. Remote MCP server URL: paste your full connection URL (ending in ?key=...)
  5. Click Add
ChatGPT
  1. Enable Developer Mode (one-time): Profile → Settings → Apps & Connectors → Advanced settings → Developer mode ON
  2. In Settings → Apps & Connectors, click Create
  3. Name: Memex · Authentication: No Authentication
  4. MCP endpoint URL: paste your full connection URL (ending in ?key=...)
  5. Click Create
Cursor / VS Code / Windsurf (via JSON config)
JSON — mcp config
{
  "mcpServers": {
    "memex": {
      "command": "npx",
      "args": [
        "mcp-remote",
        "https://YOUR_PROJECT_REF.supabase.co/functions/v1/memex-mcp",
        "--header",
        "x-brain-key:${BRAIN_KEY}"
      ],
      "env": {
        "BRAIN_KEY": "YOUR_ACCESS_KEY"
      }
    }
  }
}
⚠️ Note: no space after the colon in x-brain-key:${BRAIN_KEY}
OpenCode

Run this in your terminal (outside of opencode):

terminal
opencode mcp add
  1. Name: memex
  2. Type: Remote
  3. URL: paste your full connection URL (ending in ?key=...)
  4. OAuth: No

OpenCode writes the config to ~/.config/opencode/opencode.json automatically.

9

Use It

Your AI automatically picks the right tool based on what you say.

"What did I capture about career changes?"
→ search_thoughts
"What did I capture this week?"
→ list_thoughts
"How many thoughts do I have?"
→ thought_stats
"Find my notes about the API redesign"
→ search_thoughts
"Save this: decided to move launch to March 15..."
→ capture_thought
"Remember that Marcus wants to move to platform team"
→ capture_thought
"Show me my recent ideas"
→ list_thoughts (type: idea)
"Who do I mention most?"
→ thought_stats
🎉 You're all set. Capture thoughts from Claude Code, search from Claude Desktop, review from ChatGPT — it's all the same database.
🔒

Security Notes

What's protected and what to be aware of.

Area Status Details
MCP access key Protected All requests require your key via header or URL param. 401 returned otherwise.
Database Protected Row Level Security enabled. Only service role key (stored as Supabase secret) can access data.
Input limits Enforced Content capped at 50,000 chars. Query at 2,000. Result limits capped at 50. Prevents API credit exhaustion.
Key in URL Low risk When using ?key= (ChatGPT/Claude Desktop), the key appears in Supabase's server logs. Use the x-brain-key header method where possible (Claude Code does this by default).
Rate limiting None No rate limiting on the endpoint. If your key is leaked, someone could run up your OpenRouter bill. Rotate your key via supabase secrets set if you suspect exposure.
Secrets storage Protected All keys stored as Supabase secrets — never in code or git. Edge Function reads them at runtime only.
⚠️ If your access key is ever exposed (e.g. accidentally shared), rotate it immediately: generate a new key, run supabase secrets set MCP_ACCESS_KEY=new-key, redeploy the function, and update your AI clients.
💰

Cost Breakdown

Estimated for 20 thoughts/day.

Service What it does Cost
Supabase Database + Edge Functions hosting Free tier
text-embedding-3-small Converts thoughts to searchable vectors ~$0.02 / million tokens
gpt-4o-mini Extracts topics, people, action items ~$0.15 / million input tokens
Total (20 thoughts/day) $0.10 – $0.30 / month