Skip to main content
Glama
README.md
# L4

L4 is a personal memory / knowledge-base server: a plain markdown vault (Obsidian-style,
but nothing here depends on Obsidian) stored in Cloudflare R2, exposed to any MCP-capable
AI client over the Model Context Protocol. Point Claude, or any other MCP client, at your
own notes -- list them, read them, write them, search them by keyword or by meaning,
follow the backlink graph -- all served from a single Cloudflare Worker you own and run.

It's model-agnostic: the server speaks MCP over Streamable HTTP, so anything that can be
an MCP client can connect, not just Claude.

## Why this exists

Most "AI + your notes" setups either upload your notes into someone else's product, or
require a local process you have to keep running. L4 is neither: it's a few hundred lines
of TypeScript on Cloudflare's edge, backed entirely by Cloudflare-managed storage, that you
deploy once and then just... use, from any device, through any MCP client, over standard
OAuth.

## Architecture

```
MCP client (Claude, etc.)
      |  Streamable HTTP + OAuth 2.1 / PKCE
      v
Cloudflare Worker  (this repo)
  |-- @cloudflare/workers-oauth-provider   issues MCP-facing OAuth tokens,
  |                                         handles dynamic client registration
  |-- Cloudflare Access (OIDC)              upstream login: one-time-PIN email auth,
  |                                         gated by an email allow-list policy
  |-- R2 bucket        (source of truth)   your notes, as plain markdown + images
  |-- D1 (SQLite)       (derived index)    FTS5 full-text index, frontmatter
  |                                         metadata, wikilink graph
  |-- Vectorize + Workers AI (derived)     embeddings for semantic search
  |-- KV                                    OAuth state/grant storage
  `-- Cron Triggers                        nightly reindex + weekly vault audit
```

R2 is the only source of truth. D1 and Vectorize are both fully derived from R2 and can be
rebuilt from scratch at any time (the `l4_reindex` tool, or `/admin/reindex`) -- there is no
data in the index that isn't recoverable by re-reading the bucket.

## The 12 tools

| Tool | What it does |
|---|---|
| `l4_list` | List note paths, optionally under a folder prefix |
| `l4_read` | Read one note's text by its R2 path |
| `l4_write` | Overwrite an existing note, or create it if absent |
| `l4_create` | Create a note; errors if the path already exists |
| `l4_move` | Copy a note to a new path and delete the old one (wikilinks are plain text and are not rewritten) |
| `l4_trash` | Move a note to `_Trash/` instead of deleting it -- nothing is ever hard-deleted through MCP |
| `l4_search` | FTS5 full-text search, multi-word AND matching, ranked snippets |
| `l4_semantic` | Vector search over Workers AI embeddings -- finds related notes even with no shared keywords |
| `l4_backlinks` | List notes that `[[wikilink]]` to a given note |
| `l4_reindex` | Rebuild the D1 index and (optionally) Vectorize embeddings from R2 |
| `l4_put_image` | Store an image (base64) under `attachments/` |
| `l4_get_image` | Retrieve an image as base64 |

Two maintenance actions exist as plain REST endpoints instead of MCP tools, since they're
infra operations rather than something an AI client should trigger mid-conversation:

- `POST /admin/reindex` -- same as `l4_reindex`, callable outside the MCP/OAuth surface
- `POST /admin/audit` -- runs the weekly vault audit immediately
- `POST /admin/semantic` -- ad hoc semantic query, useful for debugging embeddings

All three require an `x-admin-token` header matching the `ADMIN_TOKEN` secret.

## Scheduled jobs

Four Cron Triggers, configured in `wrangler.jsonc` (`triggers.crons`) and dispatched in the
Worker's `scheduled` handler in `src/index.ts`:

- **Nightly reindex** (`0 7 * * *`, 07:00 UTC daily) -- rebuilds the D1 full-text index,
  frontmatter metadata, and wikilink graph from R2. Keeps the derived index in sync without
  anyone remembering to call `/admin/reindex` after bulk changes made outside the tools.
- **Weekly vault audit** (`0 10 * * 1`, Monday 10:00 UTC) -- runs seven integrity/hygiene
  checks (`src/audit.ts`) against the D1 index and writes the results as a
  `Vault audit YYYY-MM-DD.md` note back into R2:
  - **MOC drift** -- notes under `Projects/` not linked from `Projects/Projects.md`
  - **Archive drift** -- resolved/closed/done-status project notes still outside `Archive/`
  - **Stale actives** -- `status: active` notes not updated in 21+ days
  - **Broken wikilinks** -- `[[links]]` with no matching note title
  - **Trash age** -- `_Trash/` objects older than 30 days (reported, never auto-purged)
  - **Malformed frontmatter** -- missing frontmatter, or `tags:` as a bare string instead of a list
  - **Unregistered deadlines** -- notes with a `deadline:` date not linked from `Deadlines.md`

  Each check runs in its own try/catch, so one broken check reports an error in its own
  section instead of blanking the whole report. The folder names above (`Projects/`,
  `Archive/`, etc.) are just the convention this template ships with -- edit the constants
  at the top of `src/audit.ts` to match your own vault's layout.

  The same run also appends three **night shift** sections from `src/nightshift.ts`
  (read-only, cost-bounded -- see that file's header for the exact per-run cost caps):
  a what-changed-this-week summary (optionally compressed into one paragraph by a small
  Workers AI model, using only path/type/change-kind, never full note bodies), semantically
  close note pairs with no `[[wikilink]]` between them ("suggested wikilinks"), and
  near-identical note pairs ("near-duplicate candidates," e.g. conflict-fork copies).
  Suggestions only -- nothing is ever added, merged, or deleted automatically.
- **Daily review drip** (`0 11 * * *`, 11:00 UTC daily) -- optional; see "Inbound email
  capture and review drip" below. No-ops gracefully if `RESEND_API_KEY` isn't set.
- **Monthly nodal clustering** (`0 12 1 * *`, 1st of the month, 12:00 UTC) -- optional;
  see "Night shift and nodal clustering" below.

## Additional views

Three optional read-only pages, each gated by its own tiny login rather than sitting
behind the MCP OAuth flow (see "Auth for the extra views" below):

- **`/map`** -- a live 3D force-graph of your vault (`src/map.ts`): notes are colored dots
  (by first tag -- edit `TAG_COLORS` in that file to match your own tags),
  `[[wikilinks]]` are connecting lines with traffic particles, and link targets with no
  matching note ("ghost stars") show dim. A timeline slider at the bottom replays the
  vault's growth using each note's earliest frontmatter date (`created` > `date` >
  `updated`); notes with no date at all are treated as present from the beginning. Click
  a node to read the note in a side panel.
- **`/calibration`** -- a decision-calibration dashboard (`src/calibration.ts`) over notes
  tracked with the `station` / `expected` / `confidence` / `review_by` /
  `outcome_recorded` / `retro` frontmatter convention this template ships with (edit
  `DECISION_FOLDER` in that file if your own convention uses a different folder or field
  names). Shows open/partial/resolved counts, a station breakdown, overdue reviews, a
  confidence-vs-outcome scatter, and -- once any resolved decision carries both a numeric
  confidence and a machine-checkable hit/miss field -- a running Brier score. Also does a
  best-effort extraction of prediction line items from an optional note at
  `FORECASTS_PATH` (default `Forecasts.md`). Degrades gracefully: an empty vault or
  missing note just shows empty-state messaging, not a broken page.
- **`/health`** -- a read-only chart dashboard (`src/health.ts`) over four append-only log
  notes (edit `LOG_PATHS` in that file to point at your own logs, or leave any of them
  missing -- a missing log just shows up as "missing" rather than breaking the page).
  Parsing is tolerant of drifting formats: entries it can't parse are counted and kept as
  a truncated sample rather than silently dropped.

### Auth for the extra views

Cloudflare Access cannot reliably gate an individual path (like `/map`) at the edge on a
`*.workers.dev`-style hostname -- confirmed against a manually-created Self-hosted Access
Application, and consistent with reports on Cloudflare's own community forum. So each of
these three views runs its own minimal browser login instead, reusing the same
Access-for-SaaS app and `ACCESS_*`/`COOKIE_ENCRYPTION_KEY` secrets the MCP flow already
trusts: redirect to Access, verify the returned `id_token`, set our own signed session
cookie scoped to that one path. All three share the Access app's single registered
`/callback` redirect URI and tell their login traffic apart from each other and from the
MCP flow's own callback traffic by a state-parameter prefix (`map_`, `cal_`, `health_`).
If you deploy on a custom domain instead, you likely don't need this workaround and could
gate these paths with a normal Access application instead -- the code here just doesn't
assume you have one.

## Inbound email capture and review drip

Two optional features that turn email into vault input:

- **Capture** (`src/email.ts`) -- point a Cloudflare Email Routing rule at this Worker for
  an address on a domain you control, and anything you mail or forward to it lands as an
  unchecked item in the day's `Daily/YYYY-MM-DD.md` note (`- [ ] HH:MM — ...`), after
  stripping signature blocks. Evening captures (9pm-4am local time, see `VAULT_TZ`) get an
  `#late-night` tag and no implied deadline until you revisit them in a later review. Edit
  `ALLOWED_SENDERS` and `FALLBACK_INBOX` in `src/email.ts` before deploying -- only
  senders on that allow-list are accepted, and a capture that fails after acceptance is
  forwarded to the fallback inbox rather than silently lost.
- **Review drip** (`src/drip.ts`) -- once a day, the cron finds the single most-overdue
  decision note (frontmatter `review_by` in the past, `outcome_recorded` still unset) and
  emails it to you via the Resend API, then appends your plain-text reply verbatim as that
  note's `## Outcome` section when the reply comes back through Email Routing. It never
  infers, scores, or paraphrases your reply -- recording is mechanical; scoring, if any, is
  a separate step you do yourself. Requires a **Resend** account and the `RESEND_API_KEY`
  secret (optional -- the drip cron no-ops gracefully without it); edit `DRIP_TO`,
  `DRIP_FROM`, `OUTCOME_ADDRESS`, and `DECISION_PREFIXES` in `src/drip.ts` to your own
  addresses and folder before deploying.

Both require a domain added to your Cloudflare account with **Email Routing** turned on,
and a routing rule (dashboard or API -- not part of `wrangler.jsonc`) forwarding the
relevant address(es) to this Worker.

## Night shift and nodal clustering

- **Night shift** rides the weekly vault-audit note -- see "Scheduled jobs" above for what
  it adds and `src/nightshift.ts` for the cost accounting.
- **Nodal clustering** (`src/cluster.ts`), monthly: groups the entries of two source notes
  (`NODAL_PATH` and `DREAMS_PATH` -- point these at your own free-form, log-style notes, or
  adapt the two parsers if your entries use a different shape) by embedding similarity and
  writes up which entries land near each other. It's a neighbor-finder, not an
  interpreter: it lists cluster membership and nothing more, never a proposed meaning.

## Required Cloudflare resources

You need a **Workers Paid** plan -- Vectorize and cron triggers on the scale used here
aren't available on the free tier. You also need:

- A **Workers** project (this repo)
- An **R2** bucket (the vault)
- A **D1** database (the derived index)
- A **Vectorize** index (semantic search), 768 dimensions / cosine metric to match the
  `@cf/baai/bge-base-en-v1.5` embedding model used in `src/indexer.ts`
- **Workers AI** binding (runs the embedding model, no separate resource to create)
- A **KV** namespace (OAuth state/grant storage)
- A **Cloudflare Access** application (login gate in front of the OAuth flow)

Optional, only if you use the corresponding feature (see "Inbound email capture and
review drip" above):

- A domain on your Cloudflare account with **Email Routing** enabled, plus a routing rule
  pointing at this Worker
- A **Resend** account and API key (`RESEND_API_KEY`), for the review drip's outbound mail

## Setup

### 1. Create the resources

```bash
npm install
npx wrangler login

npx wrangler r2 bucket create your-vault-bucket
npx wrangler d1 create your-notes-db
npx wrangler vectorize create your-notes-index --dimensions=768 --metric=cosine
npx wrangler kv namespace create YOUR_KV_NAMESPACE_NAME
```

`d1 create` and `kv namespace create` print an ID -- you need those for the next step.

### 2. Fill in wrangler.jsonc

Open `wrangler.jsonc` and replace the placeholders:

- `r2_buckets[0].bucket_name` -- the R2 bucket name you created
- `d1_databases[0].database_id` -- the ID printed by `wrangler d1 create`
- `vectorize[0].index_name` -- the Vectorize index name you created
- `kv_namespaces[0].id` -- the ID printed by `wrangler kv namespace create`
- `account_id` (add it if you belong to more than one Cloudflare account)

Apply the schema to your new D1 database:

```bash
npx wrangler d1 execute your-notes-db --remote --file=schema.sql
```

### 3. Set up Cloudflare Access

L4 does not do its own username/password auth -- login is entirely delegated to
Cloudflare Access, which for a personal vault is usually configured as one-time-PIN email
login gated by an allow-list:

1. In the Cloudflare dashboard, go to **Zero Trust → Access → Applications** and add a
   **SaaS application** (OIDC), pointed at your Worker's `/callback` route as the redirect
   URI.
2. Add a **policy** on that application restricting access to specific email addresses
   (an allow-list of exactly the people who should be able to log in). This policy is the
   entire access-control surface -- see "Limitations" below.
3. From the Access application's OIDC settings, copy the Client ID, Client Secret, Token
   URL, Authorization URL, and JWKS URL. Those map directly to the `ACCESS_*` secrets below.

### 4. Set secrets

The Worker expects these secrets to exist (see the `secrets` block in `wrangler.jsonc`,
and `interface Env` in `worker-configuration.d.ts`):

```bash
npx wrangler secret put ACCESS_CLIENT_ID
npx wrangler secret put ACCESS_CLIENT_SECRET
npx wrangler secret put ACCESS_TOKEN_URL
npx wrangler secret put ACCESS_AUTHORIZATION_URL
npx wrangler secret put ACCESS_JWKS_URL
npx wrangler secret put COOKIE_ENCRYPTION_KEY   # any long random string, e.g. openssl rand -hex 32
npx wrangler secret put ADMIN_TOKEN             # gates /admin/*; any long random string
```

For local development with `wrangler dev`, put the same values in a `.dev.vars` file
instead (already gitignored) rather than using `wrangler secret put`.

### 5. The workers.dev gotcha (HTTP 1042)

If you deploy to a `*.workers.dev` subdomain rather than a custom domain, the OAuth
provider's internal self-fetch (it calls back into its own Worker as part of the token
exchange) can get blocked with a Cloudflare error 1042 ("blocked: request looked like it
came from Cloudflare's own network"). The fix is to add the
`global_fetch_strictly_public` compatibility flag alongside `nodejs_compat` in
`wrangler.jsonc`'s `compatibility_flags`. Custom domains generally don't hit this.

### 6. Deploy

```bash
npx wrangler deploy
```

### 7. Upload notes and build the index

Copy your markdown files into the R2 bucket (via `wrangler r2 object put`, the R2 dashboard,
`rclone`, or any S3-compatible tool -- R2 speaks the S3 API), then build the index:

```bash
curl -X POST https://<your-worker>.workers.dev/admin/reindex \
  -H "x-admin-token: <your ADMIN_TOKEN>"
```

### 8. Connect a client

Most MCP clients (Claude, etc.) support OAuth natively -- just add the server URL
(`https://<your-worker>.workers.dev/mcp`) and follow the login flow, which will bounce
through Cloudflare Access.

For clients that don't speak OAuth directly, use the `mcp-remote` proxy:

```bash
npx -y mcp-remote https://<your-worker>.workers.dev/mcp
```

## Limitations

Be honest with yourself about these before treating this as a production system:

- **No read-only scope.** Every authorized client gets the full tool set -- list, read,
  write, move, trash. There is no way to grant a client read-only access.
- **No per-client revocation.** The Cloudflare Access email allow-list is the only kill
  switch. Revoking a single MCP client's token isn't supported; removing an email from the
  Access policy is how you cut someone off.
- **No R2 object versioning configured.** `l4_write` overwrites in place. If you want
  version history or accidental-overwrite protection, turn on R2 bucket versioning
  yourself -- it isn't wired into the tool logic either way.
- **Trust model is "anyone who can log into your Access app has full read/write."** That's
  appropriate for a single-user personal vault; it is not a multi-tenant system.

## Local development

```bash
npm run dev     # wrangler dev, local
npm run type-check
npm run lint:fix
npm run format
```