# Content service The content service sits between the vault and everything that shows or changes content. It is the only thing that reads or writes the Site folder. ``` Obsidian ──edits──▶ Site folder ──watch──▶ rules ──▶ accepted store ──▶ site, admin page ▲ ▲ └──── accepted API changes ────────┤ write API (HTTP) ▲ ▲ MCP server admin page, later editors ``` - **The vault is the source of truth.** Greg edits in Obsidian; the service watches the folder and takes each change in. - **Every change runs the same rules**, whether it came from Obsidian, an agent or the admin page. A change that passes is accepted. A change that fails is held for review, and the site keeps showing the last accepted version, so a bad edit never reaches a page. - **Changes through the API are written back into the vault** once accepted, so Obsidian always shows what the site shows. The service marks its own writes as seen before writing, so the watcher does not take them in twice. - **Deleting or moving a file unpublishes the note.** Its history stays, and any version can be rolled back. ## The rules Pure functions in [`services/content/src/rules.ts`](../services/content/src/rules.ts), run in this order. The rules live in code, not in the vault, so no agent can loosen them. | Code | Holds a change when | | --- | --- | | `invalid` | The note fails its type's schema. | | `misplaced` | A settings note (`_site`, `Now/_kinds`, `Store/_settings`) is not at its fixed path. | | `duplicate-name`, `duplicate-slug` | Another live note already has the file name, or the same type already uses the address. | | `text-loss` | A body of 400 or more characters loses more than half in one edit. | | `secret` | New text looks like a key, token or password. | | `contact` | An agent adds an email address or phone number. | | `agent-edit` | An agent changes a note it did not write, unless the note has `agentEdits: true`. | | `agent-publish` | An agent publishes anything but a status or a Library item. | | `batch` | An agent makes more than 10 changes in 10 minutes. | Invalid input through the API is turned down at once with the problems, rather than held, so callers fix it themselves. A change from Obsidian that fails is held, because the file is already on disk. ## Addresses A slug change or a rename keeps the old address working as a redirect. When a note disappears and a note of the same type with the same text or title appears within ten minutes, that is treated as a move: redirects to the old path are pointed at the new one, so redirects never chain. A slug that comes back into use belongs to its note, not to the redirect. ## The write API `ContentService.apply` takes one change, `create` (type, fields, body) or `update` (path, the fields to change, `null` to remove one, and optionally the body), along with who made it. Every client uses it: - the MCP server, as `agent`; - the admin page and later editing tools, as `greg` (editor token) or `admin`; - the review queue: `acceptHeld`, `rejectHeld` (optionally restoring the live version into the vault), and `rollback` to any earlier revision. An update first takes in any unsaved Obsidian edit to that file, so an API change never overwrites one. A note with a change waiting for review takes no other API change until that one is decided. A note's type never changes. ### HTTP Every route but `/health` needs `Authorization: Bearer `. Tokens map to roles in `CONTENT_TOKENS`. | Route | Who | What | | --- | --- | --- | | `GET /health` | anyone | Note, published and held counts. | | `GET /api/schema` | all | Every note type with its JSON Schema. | | `GET /api/notes?type=&q=&limit=&offset=&full=` | all | List or search. Readers see published notes only. | | `GET /api/note?path=`, `?name=` or `?type=&slug=` | all | One note with its backlinks. An old slug answers with `{ redirect }`. | | `GET /api/backlinks?name=` | all | Notes linking to a note. | | `GET /api/redirects` | all | Every redirect. | | `GET /attachments/` | all | An image from `_attachments`. | | `GET /api/note/history?path=`, `/api/note/revision?path=&rev=` | agent, editor, admin | History and old versions. | | `GET /api/problems` | agent, editor, admin | Broken links and other whole-folder problems. | | `GET /api/held`, `/api/held/` | agent, editor, admin | The review queue; one entry comes with the current text for comparison. | | `GET /api/status` | agent, editor, admin | When the service started, its last full scan and last error, counts, and each sync job's latest run. | | `GET /api/syncs`, `?job=` | agent, editor, admin | Each sync job's latest run, or one job's recent runs. | | `POST /api/syncs` | agent, editor, admin | A sync job reports a run: `{ job, ok, message?, counts? }`. The last 50 runs per job are kept. | | `POST /api/changes` | agent, editor, admin | One change. 422 with reasons when turned down. | | `POST /api/held//accept`, `/reject` | editor, admin | Decide a held change. `{ "restore": true }` on reject puts the live version back into the vault. | | `POST /api/note/rollback` | admin | `{ path, rev }` | | `POST /api/scan` | editor, admin | Compare the whole folder now. | | `/mcp` | agent, editor, admin | The MCP server. | ### MCP Streamable HTTP at `/mcp`, stateless. Tools: `search`, `get_note`, `get_schema`, `list_held`, `set_status`, `add_library_item`, `update_fields` and `draft_post` (always unpublished). There is no tool to delete, rename, move or write a raw file. ## Running it | Variable | Default | | | --- | --- | --- | | `SITE_DIR` | required | The vault's Site folder. | | `DATA_DIR` | `data` | Holds `content.db`. Back it up: history and the review queue cannot be rebuilt from the vault. | | `PORT`, `HOST` | `4310`, `0.0.0.0` | | | `CONTENT_TOKENS` | none | `role:token` pairs, comma separated. Roles: reader, agent, editor, admin. Tokens are 24 characters or more. | | `ALLOWED_EMAILS` | none | Addresses an agent may add without a hold. | | `HEALTHCHECK_URL` | none | Pinged after every full scan. | | `NOTIFY_WEBHOOK_URL` | none | Receives `{ event: "held", held }` when a change is held. | | `SCAN_MINUTES` | `5` | Full scan interval, on top of the file watch. | ```sh SITE_DIR=/path/to/Site CONTENT_TOKENS="reader:…,agent:…,editor:…,admin:…" npm run content ``` The store is SQLite (`node:sqlite`) with full-text search, so the service has no database server to run.