Skip to main content
Glama

clewwiki

Status: pre-alpha, under active development

A self-hosted knowledge base for humans and AI coding agents to write in, together, without stepping on each other. Multiple agents (and people) get conflict-safe shared state through claims and leases instead of silent overwrites, documentation pages carry a staleness flag anchored to the code they describe, and every page comes in two linked forms — one written for humans, one written for agents — kept in sync. The server exposes both an MCP interface and a REST API. v1 runs as a single workspace with three roles: admin, editor, and scoped agent tokens.

Why

Context files that agents read on every run rot quietly. An AGENTS.md- style file drifts away from the code it describes the moment someone refactors without updating it, and nothing flags the mismatch — the next agent trusts stale instructions exactly as much as fresh ones.

Multiple agents working on the same codebase collide. Two agents editing the same page, or the same section of a shared spec, at the same time is not a hypothetical — it is the default outcome of running more than one agent against shared state with no coordination primitive. Most tools in this space either ignore the problem or bolt on an application-level heuristic instead of a real lock.

Documentation for humans and documentation for agents pull in different directions and drift apart. Prose that reads well for a person tends to be verbose and loosely structured for a model; structure that an agent parses efficiently tends to read as terse and unfriendly to a person. Maintaining one document for both audiences usually means neither is well served.

Related MCP server: agent-lore

How it works

Claim → write → release. Before writing to a page or a named section, a caller — human or agent — acquires a claim. A held claim blocks conflicting writers with an explicit conflict response instead of a silent overwrite. Writes carry the claim and the last known content hash; a stale hash is rejected so the caller always knows it is working from current state. Claims expire on a TTL and can be renewed by heartbeat while work is in progress.

Anchors. A page can anchor a section to a specific place in a code repository — a symbol, a function, a region — by content hash. When the anchored code changes, the section is flagged stale instead of being silently trusted or silently rewritten. A human or agent has to look at it and clear the flag.

Two linked document types. Every page can carry a technical body (structured, code-linked, agent-optimized) and a human body (prose, diagrams) as a linked pair. The same staleness mechanism that watches code↔doc drift also watches drift between the two linked bodies.

Deploy

What is here today: the database schema, credential login, scoped agent tokens, a container that builds, the wiki core — pages, a page tree, Markdown and Mermaid rendering, full-text search, revision history, and Markdown/HTML export — and the conflict-safe write protocol: claims on a page or a section, a presence board, ephemeral notes, and an audit log that records refused attempts as well as successful ones. All of it over both the web UI and the REST API. Anchoring and the MCP server are not here yet. What follows works today, verbatim, on a clean machine.

Prerequisites

  • Docker Engine 24 or newer with the Compose plugin (docker compose version).

  • Roughly 1 GB of free disk for the image and the database volume.

  • A machine you can reach on port 3000, or a reverse proxy in front of it.

  • openssl, for generating secrets. Any CSPRNG will do.

Nothing else: no Node.js, no PostgreSQL, no package manager on the host. Those are only needed for development, covered further down.

Quick start

1. Clone the repository.

git clone https://github.com/Dodecaidr/clewwiki.git
cd clewwiki

2. Create your environment file.

cp .env.example .env

3. Generate the two secrets. Both are required, and neither has a default — the container refuses to start if either is missing or left at a placeholder.

# Signs session cookies. Changing it later logs everyone out.
printf 'BETTER_AUTH_SECRET=%s\n' "$(openssl rand -base64 48)"

# Password for the bundled PostgreSQL container.
printf 'POSTGRES_PASSWORD=%s\n' "$(openssl rand -base64 24)"

Paste both lines into .env, replacing the empty keys already there. Then set BETTER_AUTH_URL to the address a browser will actually use. For a first local run that is:

BETTER_AUTH_URL=http://localhost:3000

If you are deploying behind a reverse proxy, set it to the public HTTPS address instead (https://wiki.example.com). It has to match what the browser sees, or sign-in cookies are issued for the wrong origin and silently dropped. Secure cookies switch on automatically when this value starts with https://.

4. Start it.

docker compose up -d

Compose builds the image, starts PostgreSQL, waits for it to pass its healthcheck, then starts the application, which applies its own database migrations before serving the first request. Watch it come up with:

docker compose logs -f web

5. Confirm it is alive.

curl http://localhost:3000/api/v1/health
{ "status": "ok", "service": "clewwiki", "database": "up" }

A "database": "down" answer means the app is running but cannot reach PostgreSQL — check docker compose logs postgres and the POSTGRES_PASSWORD in .env.

By default compose publishes the port on 127.0.0.1 only, so nothing is reachable from the network yet. That is deliberate; see Reverse proxy before changing it.

6. Create the administrator account.

Open http://localhost:3000 in a browser. Because the instance has no accounts yet, it sends you to /setup, which asks for a workspace name, your name, your email, and a password of at least 12 characters.

There are no default credentials, and there is no seeded admin account in any migration or fixture. The account you create here is the first one that exists. Once it does, /setup stops being a route at all and answers 404, so the form cannot be reached again on a running instance.

Store the password in a password manager. There is no mail transport configured by default, so there is no password-reset email to fall back on.

7. Issue an agent token.

Sign in, then open Agent tokens in the navigation (or go straight to /tokens). Give the token a name you will recognise later, choose how long it should live, and grant only the scopes the agent actually needs:

Scope

What it permits

identity:read

Call GET /api/v1/me. Needed by anything that wants to confirm who it is.

pages:read

Read wiki pages, the page tree, search results and exports.

pages:write

Create, change, move, delete and link wiki pages.

audit:read

Read the audit log. Reserved for a later phase.

Press Issue token. The token appears once:

cww_7Kq2mXpa.n4Tb9vZs1Lw0eRfHj6YcU3dQoAiKmP8gNxVtEr2sBl

Copy it now. Only a SHA-256 digest of the secret half is stored, so the value cannot be shown again or recovered — if it is lost, revoke it and issue a new one.

8. Use it.

curl -H "Authorization: Bearer $CLEWWIKI_TOKEN" \
     http://localhost:3000/api/v1/me
{
  "actor": { "type": "agent", "id": "1ced1eae-…", "name": "build-agent" },
  "role": "agent",
  "scopes": ["identity:read", "pages:read"],
  "workspace": { "id": "9c7fe5b6-…", "name": "Engineering", "slug": "default" }
}

The response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset headers so a client can slow down before it is turned away.

9. Revoke it when you are done. Press Revoke on the tokens page. The next request with that token is rejected at the authentication layer, before any handler runs:

curl -i -H "Authorization: Bearer cww_7Kq2mXpa.…" http://localhost:3000/api/v1/me
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="clewwiki"

{"error":{"code":"invalid_token","message":"Invalid or expired token"}}

Expiry behaves identically: a token past expires_at is refused at the same point, not at write time.

Writing pages

A page can be written from the browser or over the REST API, and both go through the same code — there is no "API version" of a page that behaves differently from the one the UI produces.

From the UI

Sign in and open Pages. New page asks for a title, where the page sits in the tree, its kind, and the body:

  • Kind is technical or human. The two are the linked document pair: one written for agents, one written for people. A page of each kind can be paired so that a reader of either lands on the other.

  • Path is derived from the title and the parent if you leave it empty — a page called "Auth service" under /backend becomes /backend/auth-service. Moving a page later moves everything below it.

  • Body is Markdown. A fenced block marked mermaid is rendered as a diagram in the browser:

    ```mermaid
    flowchart LR
        Agent -->|writes| Page
        Page -->|revision| History
    ```

Show preview renders the body through the same pipeline the stored page and the HTML export use, so the preview cannot show you something the page will not.

Opening the editor takes a claim on the page and holds it — the editor heartbeats while the form is open and gives the claim back when you save or leave. If somebody else, or an agent, is already holding the page, the editor says who and since when and offers the page read-only instead. It is the same lease an agent takes over REST, through the same service: a person and an agent contend for a page the same way rather than through two mechanisms that have to be kept in agreement.

From an agent token

Issue a token with pages:read and pages:write (see above), then:

export CLEWWIKI_TOKEN=cww_…
export CLEWWIKI_URL=http://localhost:3000

# Create a page.
curl -sS -X POST "$CLEWWIKI_URL/api/v1/pages" \
     -H "Authorization: Bearer $CLEWWIKI_TOKEN" \
     -H 'Content-Type: application/json' \
     -d '{
           "title": "Auth service",
           "path": "/backend/auth",
           "kind": "technical",
           "summary": "How bearer tokens are verified.",
           "body": "# Auth service\n\nTokens are verified before any handler runs.\n"
         }'
{
  "page_id": "3f0c…",
  "path": "/backend/auth",
  "title": "Auth service",
  "kind": "technical",
  "version": 1,
  "content_hash": "9f2b…",
  "updated_by": { "type": "agent", "id": "1ced…" },
  "anchors": []
}

Claim, write, release

A write needs two things: a claim — a lease on the page, or on a named section of it — and the content hash the caller last read. The claim means nobody else may write there; the hash proves nobody did. Neither is optional, and both are checked inside the same database transaction that performs the write.

# 1. Take the lease. 201 means it is yours.
curl -sS -X POST "$CLEWWIKI_URL/api/v1/pages/$PAGE_ID/claims" \
     -H "Authorization: Bearer $CLEWWIKI_TOKEN" \
     -H 'Content-Type: application/json' \
     -d '{"ttl_seconds": 600}'
{
  "claim_id": "8b41…",
  "page_id": "3f0c…",
  "held_by": "ci-writer",
  "actor_type": "agent",
  "since": "2026-05-04T09:12:11.004Z",
  "expires_at": "2026-05-04T09:22:11.004Z",
  "base_content_hash": "9f2b…"
}

If somebody else already holds the page, the answer is 409 and it names them, so a second agent can wait instead of guessing:

{
  "error": {
    "code": "conflict",
    "message": "This page is claimed by someone else",
    "details": {
      "claim_id": "0d7a…",
      "held_by": "Dana",
      "actor_type": "user",
      "since": "2026-05-04T09:10:02.881Z",
      "expires_at": "2026-05-04T09:20:02.881Z"
    }
  }
}
# 2. Write under the lease. The hash is the one the claim (or the last read)
#    handed back.
curl -sS -X PATCH "$CLEWWIKI_URL/api/v1/pages/$PAGE_ID" \
     -H "Authorization: Bearer $CLEWWIKI_TOKEN" \
     -H 'Content-Type: application/json' \
     -d "{\"body\": \"# Auth service\n\nRewritten.\n\",
          \"claim_id\": \"$CLAIM_ID\",
          \"base_content_hash\": \"$HASH\"}"

# 3. Heartbeat while the work is still going — a lease that stops being
#    renewed lapses, so a crashed client cannot hold a page forever.
curl -sS -X PATCH "$CLEWWIKI_URL/api/v1/claims/$CLAIM_ID" \
     -H "Authorization: Bearer $CLEWWIKI_TOKEN" \
     -H 'Content-Type: application/json' -d '{}'

# 4. Give it back.
curl -sS -X DELETE "$CLEWWIKI_URL/api/v1/claims/$CLAIM_ID" \
     -H "Authorization: Bearer $CLEWWIKI_TOKEN"

A write whose base_content_hash no longer matches is refused with 409 stale_base, carrying both hashes, even when the claim itself is perfectly valid — someone with a section claim, or an administrator, may have written in between. The caller re-reads, merges, and writes again; the server never merges on anyone's behalf:

{
  "error": {
    "code": "stale_base",
    "message": "Page has changed since it was read",
    "details": { "current_content_hash": "c41e…", "your_base_hash": "9f2b…" }
  }
}

A claim can cover one named section instead of the whole page, which is what lets two writers work on one document at the same time:

curl -sS -X POST "$CLEWWIKI_URL/api/v1/pages/$PAGE_ID/claims" \
     -H "Authorization: Bearer $CLEWWIKI_TOKEN" \
     -H 'Content-Type: application/json' \
     -d '{"section_id": "api-reference"}'

Two section claims coexist while they name different sections. A page-level claim excludes every section claim on that page, and every section claim excludes a page-level one.

Until anchors arrive, a section claim controls who may write, not which bytes they may write: the server has no section boundaries to check a body against yet, so a write under a section claim still replaces the whole body. Two holders of different sections writing at once are separated by the content hash instead — the second one is refused with stale_base, re-reads, and writes again. Nothing is lost either way.

Presence and notes

GET /api/v1/claims answers with every claim held in the workspace right now. The same data is on the Presence page in the UI, refreshed every ten seconds, with a badge in the page tree and in the page header so a reader sees that a page is spoken for before opening the editor.

curl -sS -H "Authorization: Bearer $CLEWWIKI_TOKEN" "$CLEWWIKI_URL/api/v1/claims"
{
  "claims": [
    {
      "claim_id": "8b41…",
      "page_id": "3f0c…",
      "path": "/backend/auth",
      "section_id": "api-reference",
      "held_by": "ci-writer",
      "actor_type": "agent",
      "since": "2026-05-04T09:12:11.004Z",
      "expires_at": "2026-05-04T09:22:11.004Z",
      "notes": [
        {
          "note_id": "af02…",
          "text": "Rewriting the API reference, leave Overview alone.",
          "author": "ci-writer",
          "created_at": "2026-05-04T09:12:40.119Z"
        }
      ]
    }
  ]
}

A note says what its author is doing while an edit is in flight. It hangs on a claim, it is never part of the page's history, and it is deleted the moment the claim ends — by release, by expiry, or by an administrator:

curl -sS -X POST "$CLEWWIKI_URL/api/v1/pages/$PAGE_ID/notes" \
     -H "Authorization: Bearer $CLEWWIKI_TOKEN" \
     -H 'Content-Type: application/json' \
     -d "{\"claim_id\": \"$CLAIM_ID\", \"text\": \"Rewriting the API reference.\"}"

Notes are text somebody wrote, shown with their name and timestamp. Like page bodies, they are data — nothing that reads them treats them as instructions.

When a lease outlives its holder. Claims expire on their own: the TTL defaults to ten minutes, a caller may ask for anything between one second and one hour, and a workspace can set its own default. Until then a stuck claim can be taken away by a workspace administrator, from the presence board or with DELETE /api/v1/claims/{claimId}?force=true. That is an administrator-only action and it is written to the audit log under its own name. Agent tokens cannot force-release anything, whatever scopes they carry.

Search, history and the tree:

curl -sS -H "Authorization: Bearer $CLEWWIKI_TOKEN" \
     "$CLEWWIKI_URL/api/v1/search?q=bearer&limit=5"
curl -sS -H "Authorization: Bearer $CLEWWIKI_TOKEN" \
     "$CLEWWIKI_URL/api/v1/pages/$PAGE_ID/versions"
curl -sS -H "Authorization: Bearer $CLEWWIKI_TOKEN" \
     "$CLEWWIKI_URL/api/v1/pages/$PAGE_ID/tree"

Export

Every page exports to Markdown or HTML, from the Export menu on the page or straight from the API. Both are rendered from the page itself with nothing fetched at export time, so an export keeps working when everything around it does not:

curl -sS -H "Authorization: Bearer $CLEWWIKI_TOKEN" \
     "$CLEWWIKI_URL/api/v1/export/$PAGE_ID?format=md" -o auth.md

curl -sS -H "Authorization: Bearer $CLEWWIKI_TOKEN" \
     "$CLEWWIKI_URL/api/v1/export/$PAGE_ID?format=html" -o auth.html

The Markdown file is the body exactly as stored, under front matter carrying the title, path, kind, version, content hash and modification time. The HTML file is a standalone document that opens from disk with nothing to load; Mermaid blocks are kept as <pre class="mermaid"> holding their source, since drawing them would mean shipping a renderer inside every exported file.

Anchoring a page to code

An anchor ties a page, or one named section of it, to a declaration in your source repository. When the code changes, the page is flagged — it is never rewritten, and nothing is trusted silently.

Link a repository once, as an administrator. Open Repository in the header and fill in three fields:

Field

What it takes

Repository URL

https://…, ssh://…, or file:///srv/checkouts/api for a repository on the same machine.

Default ref

The branch, tag or commit anchors are checked against unless a caller names another. Usually main.

Access token variable

The name of an environment variable holding the token — for example GIT_ACCESS_TOKEN. Leave it empty for a public or local repository.

The token itself is never typed into the form and never stored: the setting records the variable's name, and the server reads the value out of its own environment when it talks to git. Test connection checks the URL, the ref and the token without cloning anything.

The server keeps a read-only bare mirror per workspace under REPOS_DIR, fetches it on demand, and reads files with git show. It never creates a working tree, never runs a build, an install script or a hook, and never executes anything it finds in your repository.

Add an anchor from the Anchored code panel on any page — a file path plus either a symbol (Mixer.blend(first:second:)) or a line range (42-58) — or over the API:

curl -sS -X POST -H "Authorization: Bearer $CLEWWIKI_TOKEN" \
     -H 'Content-Type: application/json' \
     -d '{"file":"Sources/Audio/Mixer.swift","qualified_name":"Mixer.blend(first:second:)"}' \
     "$CLEWWIKI_URL/api/v1/pages/$PAGE_ID/anchors"

The declaration is resolved against the repository before the anchor is stored, so a misspelled symbol is refused there and then rather than reported as lost a week later. Swift, TypeScript and TSX have declaration tables today; any other file can still be anchored by line range.

Check recomputes every anchor on a page against the repository:

curl -sS -H "Authorization: Bearer $CLEWWIKI_TOKEN" \
     "$CLEWWIKI_URL/api/v1/pages/$PAGE_ID/anchors/check?ref=main"

Each anchor comes back in one of four states, and they mean different things:

State

What happened

What to do

fresh

The declaration is there and unchanged.

Nothing.

stale

Same declaration, changed body.

Read the diff, update the page, confirm.

moved-renamed

The declaration was found elsewhere, or under another name. The response says where and what it is called now.

Confirm, which re-points the anchor.

lost

Nothing resolvable.

A decision, not an edit: fix the page, or delete the anchor.

A formatting change does not flag anything. The hash covers the parser's token sequence rather than the file's text, so re-indenting a function, wrapping its arguments or rewriting its comments changes nothing the checker looks at; a change to what the code does changes the hash.

Confirm is the only thing that clears a flag, and it is a deliberate, audited act by a person or an agent:

curl -sS -X POST -H "Authorization: Bearer $CLEWWIKI_TOKEN" \
     "$CLEWWIKI_URL/api/v1/anchors/$ANCHOR_ID/confirm"

The check response also carries fallback_share — the share of the workspace's anchors sitting on the line-range path. Line ranges do not survive an edit above them, so a rising number is the early warning that the badges are turning into noise.

REST endpoints in this phase

Endpoint

Auth

Scope

Purpose

GET /api/v1/health

none

Liveness and database reachability. Used by the compose healthcheck.

GET /api/v1/me

session or token

identity:read

Who the caller is, what it may do, and which workspace it is bound to.

GET /api/v1/pages

session or token

pages:read

The page tree, without bodies. Takes parent_id, path, kind, depth.

POST /api/v1/pages

session or token

pages:write

Create a page.

GET /api/v1/pages/{id}

session or token

pages:read

One page with its body, content hash and linked counterpart.

PATCH /api/v1/pages/{id}

session or token

pages:write

Update or move a page under a claim. Requires claim_id and base_content_hash. Writes a revision and bumps the version.

DELETE /api/v1/pages/{id}

session or token

pages:write

Soft-delete a page and everything below it.

GET /api/v1/pages/{id}/tree

session or token

pages:read

The subtree rooted at a page, nested.

GET /api/v1/pages/{id}/versions

session or token

pages:read

Revision history: version, author, content hash, timestamp.

POST /api/v1/pages/{id}/link

session or token

pages:write

Pair a technical page with a human one, or unpair them.

POST /api/v1/pages/{id}/claims

session or token

pages:write

Take a claim on the page, or on a section of it. 201 when granted, 200 when it extends a lease the caller already held, 409 when someone else holds it.

GET /api/v1/pages/{id}/claims

session or token

pages:read

The live claims on one page.

PATCH /api/v1/claims/{claimId}

session or token

pages:write

Heartbeat: extends a lease the caller holds.

DELETE /api/v1/claims/{claimId}

session or token

pages:write

Release a claim and delete its notes. Idempotent. ?force=true is administrator-only.

GET /api/v1/claims

session or token

pages:read

The presence board: every claim held in the workspace, with its notes.

POST /api/v1/pages/{id}/notes

session or token

pages:write

Leave an ephemeral note on a claim the caller holds.

GET /api/v1/pages/{id}/notes

session or token

pages:read

The active notes on a page.

POST /api/v1/pages/{id}/anchors

session or token

pages:write

Anchor the page, or one of its sections, to a declaration or a line range. Resolves it against the repository first.

GET /api/v1/pages/{id}/anchors

session or token

pages:read

The anchors on one page, plus the workspace's fallback_share.

GET /api/v1/pages/{id}/anchors/check

session or token

pages:read

Recompute every anchor on the page against the repository. Takes ref.

POST /api/v1/anchors/{anchorId}/confirm

session or token

pages:write

Clear a flag after review, re-baselining the anchor onto what is there now.

DELETE /api/v1/anchors/{anchorId}

session or token

pages:write

Remove an anchor.

GET /api/v1/audit

admin session or token

audit:read

The audit log, newest first. Takes action, target, since, limit.

GET /api/v1/search

session or token

pages:read

Full-text search. Takes q, limit, kind.

GET /api/v1/export/{id}

session or token

pages:read

Export a page. Takes format=md or format=html.

All of them refuse to answer for a workspace other than the caller's own, with 404 rather than 403 so the response does not confirm that a page exists somewhere else. That check is written explicitly in each handler rather than inferred from there being one workspace, so it does not have to be retrofitted when there is more than one. Errors share one envelope:

{ "error": { "code": "stale_base", "message": "…", "details": { } } }

The codes are validation, not_found, conflict, stale_base, forbidden, insufficient_scope, unauthenticated, invalid_token, rate_limited and repository_unavailable. On the write path they mean particular things: conflict is "you have no claim here", not_found on a write is "your claim has expired or been released", forbidden is "that claim belongs to someone else", and stale_base is "the page moved under you". repository_unavailable is a 502: the workspace's source repository could not be reached or read, which is this instance's dependency failing rather than anything wrong with the request.

Reverse proxy

clewwiki does not terminate TLS, and it never will. The application process does not listen on a TLS socket, does not read certificates, and has no ACME client. Putting a proxy in front of it is not optional for anything reachable beyond localhost: without one, session cookies and agent tokens cross the network in plaintext.

No proxy is bundled, so you can use the one you already run rather than fight a second one. Three worked examples follow. In all of them:

  1. Set BETTER_AUTH_URL in .env to the public https:// address.

  2. Set WEB_BIND_ADDRESS=127.0.0.1 (the default) if the proxy runs on the same host, so the app is unreachable except through the proxy.

  3. Make sure the proxy sends X-Forwarded-Proto: https. The app enables HSTS only when it sees that header, which is what keeps a plain-HTTP local run from locking your browser out of the instance.

  4. Run docker compose up -d again after editing .env.

Caddy

Caddy obtains and renews certificates from Let's Encrypt on its own, and sets the forwarding headers correctly with no configuration. It is the least you can get away with:

# /etc/caddy/Caddyfile
wiki.example.com {
	encode zstd gzip

	reverse_proxy 127.0.0.1:3000 {
		header_up X-Forwarded-Proto {scheme}
	}
}
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

Point wiki.example.com at the host first: Caddy needs to answer an ACME challenge on port 80 before it can serve port 443.

Traefik

For a Traefik instance already running on a shared Docker network, add labels to the web service in a compose override file rather than editing docker-compose.yml:

# docker-compose.override.yml
services:
  web:
    # The proxy reaches the app over the Docker network, so stop publishing
    # the port on the host entirely.
    ports: !reset []
    networks:
      - default
      - traefik
    labels:
      traefik.enable: 'true'
      traefik.docker.network: traefik
      traefik.http.routers.clewwiki.rule: Host(`wiki.example.com`)
      traefik.http.routers.clewwiki.entrypoints: websecure
      traefik.http.routers.clewwiki.tls: 'true'
      traefik.http.routers.clewwiki.tls.certresolver: letsencrypt
      traefik.http.services.clewwiki.loadbalancer.server.port: '3000'

networks:
  traefik:
    external: true

Traefik sets X-Forwarded-Proto itself. Confirm your entrypoint has a certificate resolver configured and that the traefik network exists (docker network create traefik).

nginx

nginx needs the forwarding headers spelled out, and needs its proxy buffering relaxed for streaming responses:

# /etc/nginx/sites-available/clewwiki
server {
    listen 80;
    listen [::]:80;
    server_name wiki.example.com;

    # Everything except the ACME challenge goes to HTTPS.
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }
    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name wiki.example.com;

    ssl_certificate     /etc/letsencrypt/live/wiki.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/wiki.example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;

    # Agent tokens are sent as headers; keep them off any cache.
    proxy_cache off;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;

        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        # Without this the app will not enable secure-cookie or HSTS behaviour.
        proxy_set_header X-Forwarded-Proto $scheme;

        # Required for streamed responses and, later, the MCP HTTP transport.
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_buffering off;
        proxy_read_timeout 300s;
    }
}
sudo ln -s /etc/nginx/sites-available/clewwiki /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Obtain the certificate with certbot certonly --webroot -w /var/www/certbot -d wiki.example.com before enabling the HTTPS block.

Security checklist

Work through this before exposing an instance to anything but your own machine. Each line is pass or fail, not a matter of judgement.

  • TLS is terminated in front of the app. Load the instance over https:// and confirm the certificate is valid. The app never does this itself, and there is no warning if you skip it.

  • BETTER_AUTH_SECRET is a generated value. openssl rand -base64 48, not a word you chose and not the placeholder. The container refuses to start on an obvious placeholder, but it cannot detect a weak one.

  • POSTGRES_PASSWORD is a generated value, and the database port is not published to the host. The shipped compose file keeps it on the internal network; check any override you added.

  • .env is not in version control. git status should never list it. .gitignore covers it, but a git add -f defeats that.

  • The administrator account has a strong, stored password. It was created interactively at first boot; there is no reset email configured.

  • /setup returns 404. Check it. A 200 means the instance has no accounts and anyone reaching it can claim the administrator account.

  • Agent tokens carry an expiry. Prefer a fixed TTL and rotation over a token that never expires.

  • Agent tokens carry the narrowest scopes that work. A token that only reads should not hold pages:write.

  • Leaked tokens are revoked, not just rotated. Revocation takes effect on the next request, at the authentication layer.

  • The rate limit suits your agents. The default is 60 requests per minute per token. It is enforced per application process, so running more than one replica multiplies the effective ceiling.

  • The audit log is being read. Every agent-token request writes a row, including rejected ones. A burst of auth.rejected from a token that is normally quiet is the signal that a token has leaked.

  • Backups cover the postgres-data volume. It holds every account, token digest and audit row. docker compose down -v deletes it permanently.

  • Image and dependencies are current. docker compose build --pull then docker compose up -d picks up base-image security updates.

Configuration reference

Every value is read from the environment. Nothing is compiled into the image, and there is no configuration file to edit inside the container.

Variable

Secret

Default

Purpose

POSTGRES_PASSWORD

yes

none — required

Password for the bundled PostgreSQL container.

POSTGRES_USER

no

clewwiki

Database role name.

POSTGRES_DB

no

clewwiki

Database name.

DATABASE_URL

yes

built by compose

Connection string. Set it by hand only when running outside compose.

BETTER_AUTH_SECRET

yes

none — required

Signs session cookies and tokens. Changing it invalidates every session.

BETTER_AUTH_URL

no

http://localhost:3000

Public base URL as the browser sees it. Enables secure cookies when it starts with https://.

APP_BASE_URL

no

BETTER_AUTH_URL

Base URL used for generated links.

AGENT_TOKEN_RATE_LIMIT_MAX

no

60

Requests allowed per token per window.

AGENT_TOKEN_RATE_LIMIT_WINDOW

no

60

Window length in seconds.

WEB_BIND_ADDRESS

no

127.0.0.1

Host interface compose publishes on. 0.0.0.0 exposes the app to the network — only with TLS in front.

WEB_PORT

no

3000

Host port compose publishes on.

RUN_MIGRATIONS_ON_START

no

unset (migrations run)

Set to false to manage the schema yourself.

CLAIM_SWEEP_INTERVAL_SECONDS

no

60

How often lapsed claims are released in the background. 0 disables the sweep; expiry is still applied whenever a claim is read or written.

CLEWWIKI_MIGRATIONS_DIR

no

set by the image

Where the app looks for migration SQL.

REPOS_DIR

no

/data/repos

Where read-only mirrors of workspace repositories are kept. Compose backs it with a named volume.

CLEWWIKI_GRAMMARS_DIR

no

set by the image

Where the app looks for the tree-sitter grammar .wasm files.

LOG_LEVEL

no

info

One of error, warn, info, debug.

Browser sessions are not rate limited; the two AGENT_TOKEN_RATE_LIMIT_* settings apply to bearer-token requests only.

Upgrading

git pull
docker compose build --pull
docker compose up -d

Pending migrations are applied by the application when it starts. Several replicas starting at once are safe: an advisory lock means the first one migrates and the rest wait, then find nothing to do.

Uninstalling

docker compose down        # stop, keep the data
docker compose down -v     # stop and delete the database volume

The second form is irreversible: accounts, tokens and audit history go with it.

Development

For working on clewwiki itself rather than running it, you need Node.js 22 (.nvmrc), pnpm 10, and a PostgreSQL 16 you can point at.

pnpm install
cp .env.example .env          # then fill DATABASE_URL and BETTER_AUTH_SECRET

pnpm db:generate              # regenerate migrations after a schema change
pnpm db:migrate               # apply migrations
pnpm dev                      # development server on :3000

pnpm lint
pnpm typecheck
pnpm test
pnpm build

The unit tests need nothing but Node. The integration tests need a throwaway PostgreSQL database and are skipped, with a message, when they cannot find one:

createdb clewwiki_test
TEST_DATABASE_URL=postgres://localhost:5432/clewwiki_test pnpm test

They apply migrations themselves, create their own workspaces, and delete them afterwards, so an existing database is not disturbed — but point them at a scratch database anyway.

Security model

clewwiki is designed for an operator with no dedicated security team and often no reverse-proxy experience — that is treated as the normal case, not an edge case. The security model includes:

  • Per-agent scoped tokens with TTL and explicit revocation, rejected at the authentication layer once expired or revoked — not just at write time.

  • A write audit log: every write attempt (success, claim conflict, or hash conflict) is recorded in the same transaction as the attempt itself.

  • Claims are time-boxed leases (TTL with renewal), not indefinite locks.

  • Rate limiting per agent token, to contain a runaway or buggy client.

  • Workspace-scoped access checks on every request, written explicitly in code rather than assumed from a single-workspace deployment.

  • Document content is always treated as data, never as instructions, in every response shape returned to an agent.

  • No reverse proxy is bundled by default. TLS is the operator's responsibility; worked examples for Caddy, Traefik, and nginx are in Reverse proxy above.

A full write-up lives in docs/security.md.

Roadmap

No calendar dates — phases are ordered by dependency, not by schedule.

  • Phase 0 — Anchor mechanism spike, project bootstrap, CI skeleton with dependency and secret scanning.

  • Phase 1 — Data model and auth: workspace, users, roles, agent tokens. Complete.

  • Phase 2 — Wiki core: pages, page tree, full-text search, REST API. Complete.

  • Phase 3 — Claims and leases, presence board, ephemeral agent notes. Complete.

  • Phase 4 — Doc↔code anchoring with staleness detection. Complete.

  • Phase 5 — MCP server (stdio and streamable HTTP transports).

  • Phase 6 — Export (Markdown/HTML, PDF conditional on a spike), Docker image and compose, full README and license text.

  • Phase 7 — Public launch.

See docs/roadmap.md for exit criteria per phase.

License

clewwiki is licensed under AGPL-3.0 (see LICENSE), with additional terms permitted under AGPL-3.0 Section 7 covering author attribution and marking of modified versions (see LICENSE-ADDITIONAL-TERMS.md).

Author

clewwiki is created and maintained by Dodecaidrhttps://dodecaidr.pro.site

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    B
    maintenance
    MCP server that gives AI coding agents a git-backed markdown wiki to read and update, enabling search, read, write, verify, ingest, promote, and lint operations on versioned knowledge documents with schema validation, staleness tracking, and contradiction detection.
    4
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    A machine-local, agent-writable knowledge base MCP server that lets coding agents autonomously record and retrieve hard-won operational facts, incidents, and corrections as markdown pages in a git repo, with provenance, talk pages, and search.
    4 npm
    MIT