Index: docs/cirkels-v1-spec.md
===================================================================
--- docs/cirkels-v1-spec.md	(revision 0b7ebbf536d348524f3dd4438dc3096a9055aac5)
+++ docs/cirkels-v1-spec.md	(revision 0b7ebbf536d348524f3dd4438dc3096a9055aac5)
@@ -0,0 +1,278 @@
+# Klonkt — Cirkels (v1-spec)
+
+> Derde tenancy-optie naast **solo** en **hub**. Laat zelf-gehoste **solo-instances**
+> elkaars publieke content tonen, **decentraal** en **asymmetrisch** — zónder centraal
+> punt, zónder centrale moderatie, zónder dat iemand (ook Robin niet) de cirkel bezit.
+
+Status: ontwerp. Bouwt voort op de bestaande tenancy-laag (`SettingsService`,
+`app_settings`, `resolveSite`/`site.js`, `sites`-schema).
+
+---
+
+## 1. Principes (waarom dit veilig is voor het self-host-model)
+
+1. **Geen centrale content.** Elke instance hbost z'n eigen posts. Een cirkel is
+   enkel een *verzameling verbindingen*, geen opslag.
+2. **Asymmetrisch (volgen, geen vriendschap).** A toont B omdat A dat kiest — los van
+   of B A toont. "4 bandleden droppen de 5e" kan; de 5e houdt de 4 gewoon in z'n eigen
+   cirkel. Dit is de Mastodon/Twitter-`Follow`-semantiek, niet de Facebook-`Friend`.
+3. **Per-instance beheer + zelf-policing.** Elke admin beheert *zijn eigen* cirkel
+   (Klonkt-URL's toevoegen/weghalen). Bevalt een bron niet → eruit. Geen centrale
+   autoriteit, dus geen moderatie-/aansprakelijkheidslast bij de leverancier.
+4. **Publiek = publiek.** Een cirkel toont alleen reeds-publieke posts (zoals RSS /
+   embedding van het open web). Een bron kan zich wel **afmelden** voor surfacing
+   (zie §6, `allow_circle`).
+5. **Standaard-compatibel datamodel, simpel transport.** De content krijgt de **vorm**
+   van **ActivityStreams 2.0 / schema.org**, maar v1 **transporteert** via een
+   simpele **getekende pull** (geen volledige ActivityPub-server). De echte AP-brug
+   (inbox/outbox, WebFinger, HTTP-signatures) komt in v2 als fediverse-koppeling.
+   → Eert Bart's "gebruik de standaarden" én Robin's "licht & tolerant eerst".
+6. **v1 = statisch.** Gecachte publieke kaarten. **Cross-instance comments/interactie
+   blijft GEPARKEERD** — dáár komt de moderatie/abuse-ellende terug.
+
+---
+
+## 2. Tenancy-model
+
+`app_settings.tenancy` krijgt een derde waarde:
+
+| Modus | Structuur |
+|---|---|
+| `solo` | 1 site, geen federatie |
+| `hub` | 1 instance, N sites, centraal beheerd |
+| `circle` | 1 solo-site **+** een cirkel-feed van remote Klonkt-instances |
+
+`circle` = functioneel "solo + cirkel-feature aan". `getTenancy()` wordt uitgebreid;
+`resolveSite` gedraagt zich voor de lokale routes identiek aan `solo` (pin de primaire
+site), met daarbovenop de cirkel-routes (§5).
+
+```js
+// SettingsService.js
+export function getTenancy() {
+  const v = getSetting('tenancy', 'solo');
+  return v === 'hub' ? 'hub' : v === 'circle' ? 'circle' : 'solo';
+}
+```
+
+---
+
+## 3. Datamodel (nieuwe migratie)
+
+Hergebruik bestaand: `sites.origin_server` (al `'local'` default), `sites.is_public`,
+`sites.closed_circle_mode`. Nieuw:
+
+```sql
+-- Wie de lokale site volgt (asymmetrisch, lokaal beheerd)
+CREATE TABLE IF NOT EXISTS circle_links (
+    id            TEXT PRIMARY KEY,
+    local_site_id TEXT NOT NULL,         -- onze site die deze bron toont
+    remote_url    TEXT NOT NULL,         -- canonieke instance-URL (https://...)
+    remote_actor_id TEXT,                -- ingevuld na eerste fetch
+    label         TEXT,                  -- admin-notitie / weergavenaam
+    status        TEXT DEFAULT 'active', -- active | paused | error
+    added_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    last_synced   DATETIME,
+    last_error    TEXT,
+    UNIQUE(local_site_id, remote_url),
+    FOREIGN KEY (local_site_id) REFERENCES sites(id)
+);
+
+-- Gecachete remote actor (incl. publieke sleutel voor verificatie)
+CREATE TABLE IF NOT EXISTS remote_actors (
+    id            TEXT PRIMARY KEY,      -- = actor.id (de canonieke URL)
+    url           TEXT UNIQUE NOT NULL,
+    name          TEXT,
+    summary       TEXT,
+    avatar        TEXT,
+    public_key    TEXT NOT NULL,         -- Ed25519, base64
+    fetched_at    DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Gecachete remote posts (statische snapshot, AS-object)
+CREATE TABLE IF NOT EXISTS remote_posts (
+    id            TEXT PRIMARY KEY,      -- = object.id (remote canonieke URL)
+    actor_id      TEXT NOT NULL,
+    published     DATETIME,
+    title         TEXT,
+    summary       TEXT,                  -- platte tekst, gesanitized
+    url           TEXT,                  -- link terug naar de bron
+    media_json    TEXT,                  -- genormaliseerde media-refs (JSON)
+    raw_json      TEXT,                  -- origineel AS-object (audit)
+    fetched_at    DATETIME DEFAULT CURRENT_TIMESTAMP,
+    FOREIGN KEY (actor_id) REFERENCES remote_actors(id)
+);
+```
+
+> `circle_links` is bewust **per `local_site_id`** zodat het model meteen klopt als een
+> hub later óók cirkels wil (elke site z'n eigen cirkel). Voor solo is er één site.
+
+---
+
+## 4. Het protocol (v1: getekende pull, AS-vormig)
+
+Elke Klonkt-instance publiceert **twee statische, publieke JSON-documenten** op vaste
+paden. Geen auth, geen inbox — alleen *lezen*.
+
+### 4a. Actor — `GET /.klonkt/actor.json`
+
+ActivityStreams `Person`/`Service`-actor met de **publieke sleutel** (Ed25519):
+
+```json
+{
+  "@context": ["https://www.w3.org/ns/activitystreams", "https://schema.org/"],
+  "type": "Person",
+  "id": "https://joostklein.klonkt.com/.klonkt/actor.json",
+  "name": "Joost Klein",
+  "summary": "Frisse gozer met platen.",
+  "url": "https://joostklein.klonkt.com/",
+  "icon": { "type": "Image", "url": "https://joostklein.klonkt.com/img/avatar.webp" },
+  "outbox": "https://joostklein.klonkt.com/.klonkt/outbox.json",
+  "publicKey": {
+    "id": "https://joostklein.klonkt.com/.klonkt/actor.json#key",
+    "owner": "https://joostklein.klonkt.com/.klonkt/actor.json",
+    "algorithm": "ed25519",
+    "publicKeyBase64": "M0r3...base64..."
+  },
+  "klonkt": { "version": 1, "allowCircle": true }
+}
+```
+
+### 4b. Outbox — `GET /.klonkt/outbox.json`
+
+AS `OrderedCollection` van recente `Create`→`Note`/`Audio`-objecten (alleen publieke
+posts). Statisch gegenereerd bij elke post-mutatie (cache-bestand of route).
+
+```json
+{
+  "@context": "https://www.w3.org/ns/activitystreams",
+  "type": "OrderedCollection",
+  "id": "https://joostklein.klonkt.com/.klonkt/outbox.json",
+  "totalItems": 2,
+  "orderedItems": [
+    {
+      "type": "Create",
+      "id": "https://joostklein.klonkt.com/posts/zomer-2026#create",
+      "published": "2026-06-14T18:00:00Z",
+      "actor": "https://joostklein.klonkt.com/.klonkt/actor.json",
+      "object": {
+        "type": "Article",
+        "id": "https://joostklein.klonkt.com/posts/zomer-2026",
+        "name": "Zomerplaat",
+        "summary": "Nieuwe single uit.",
+        "url": "https://joostklein.klonkt.com/posts/zomer-2026",
+        "published": "2026-06-14T18:00:00Z",
+        "attachment": [
+          { "type": "Audio", "url": "https://joostklein.klonkt.com/audio/stream/zomer.mp3",
+            "name": "Zomerplaat", "duration": "PT3M21S" }
+        ]
+      }
+    }
+  ]
+}
+```
+
+### 4c. Tekenen + verifiëren (anti-spoofing)
+
+- De instance tekent het **outbox-document** met z'n Ed25519-private sleutel.
+  v1-keuze: **detached signature in een HTTP-header** bij de outbox-respons:
+  `Klonkt-Signature: ed25519=<base64(sig over raw body)>`.
+  (Eenvoudiger dan HTTP Message Signatures; v2 kan naar de RFC-9421-variant.)
+- Consument: fetch `actor.json` → pak `publicKeyBase64` → fetch `outbox.json` →
+  verifieer `Klonkt-Signature` over de **ruwe body** met die sleutel. Mismatch → drop +
+  `circle_links.status='error'`, `last_error` gevuld.
+- **TOFU** (trust-on-first-use): de eerste keer wordt de pubkey gecachet in
+  `remote_actors`. Verandert 'ie later → waarschuw de admin (key-rotation = expliciete
+  her-bevestiging), zoals SSH.
+
+> Sleutelopslag lokaal: genereer per-instance één Ed25519-keypair bij eerste start,
+> bewaar in `app_settings` (`circle_privkey` / `circle_pubkey`, base64). Privé-sleutel
+> nooit serveren; alleen de publieke in `actor.json`.
+
+---
+
+## 5. Server-flow & routes
+
+### 5a. Publiceren (onze kant)
+- Genereer/ververs `/.klonkt/actor.json` + `/.klonkt/outbox.json` (alleen `is_public`
+  posts; respecteer `allow_circle`). Trigger: post-create/update/delete + nightly.
+- Statisch cachen (bestand of in-memory) + de `Klonkt-Signature`-header zetten.
+
+### 5b. Pullen (cirkel verversen) — `CircleService.sync()`
+Periodiek (cron, bv. elke 15 min) + handmatige "ververs"-knop:
+```
+voor elke circle_links (status=active):
+  fetch remote_url + '/.klonkt/actor.json'      (volg redirect naar canoniek)
+  upsert remote_actors (pubkey TOFU-check)
+  fetch actor.outbox  (met Klonkt-Signature)
+  verifieer signature met pubkey   -> faal? status=error, continue
+  voor elk Create/object: sanitize + upsert remote_posts
+  circle_links.last_synced = now, status=active
+```
+Robuust/tolerant: time-outs, max body-size, alleen `https`, alleen verwachte velden,
+onbekende velden negeren (Postel's law — Bart's "tolerant & robuust").
+
+### 5c. Tonen — nieuwe route `/cirkel` (+ feed-blok op de home)
+- `routes/circle.js` (mount alleen als `tenancy==='circle'`): toont een
+  tijd-gesorteerde **statische kaarten-feed** van `remote_posts` (titel, samenvatting,
+  bron-avatar/naam, "via <instance>"-badge, link terug naar de bron). Geen interactie.
+- Optioneel een compacte "Uit je cirkel"-strook op de solo-home.
+- Media: v1 toont een **link/representatie** terug naar de bron (geen herhosting). De
+  `media_json` mag een resting-kaart renderen die naar de bron-URL linkt (embeddable
+  komt in v2; nu geen cross-host streaming/CSP-gedoe).
+
+### 5d. Beheer-UX — Beheer → **Cirkel**
+- Lijst van `circle_links` met status (✓ active / ⏸ paused / ⚠ error + reden).
+- Input "Voeg een Klonkt-site toe" (plak URL) → validatie (fetch actor, toon
+  naam/avatar ter bevestiging) → opslaan.
+- Per bron: pauzeren / verwijderen / nu-verversen.
+- Toggle **"Mijn site mag in cirkels van anderen verschijnen"** → `sites.is_public`
+  i.c.m. een nieuwe `allow_circle`-flag (default aan) → stuurt `actor.allowCircle`.
+
+---
+
+## 6. Privacy, veiligheid, edge-cases
+
+- **Opt-out van surfacing**: `allow_circle=0` → onze `actor.json`/`outbox.json` geven
+  `allowCircle:false` + lege outbox. Een nette consument respecteert dat (zoals
+  robots). Hard afdwingen kan niet (publieke bytes) — eerlijk benoemen, zoals het web.
+- **Remote HTML-sanitatie**: remote `summary`/titels strikt strippen naar platte tekst
+  vóór opslag/rendering (geen remote HTML/CSS in onze DOM → geen XSS-import; vgl. de
+  `mailapp`-keuze om geen vreemde HTML te injecteren).
+- **Alleen https**, redirect-limiet, body-size-limiet, fetch-timeout, rate-limit per
+  bron. Verifieer dat `object.id`/`url` op **hetzelfde origin** als de actor staan
+  (anti-impersonatie: bron mag geen posts "namens" een andere instance claimen).
+- **Key-rotation** = expliciete admin-herbevestiging (TOFU).
+- **Verwijderingen**: outbox is de bron van waarheid; remote_posts die niet meer in de
+  outbox staan → opruimen (tombstone of hard delete) bij sync.
+- **Geen wederkerigheid afdwingen** — bewust. Discovery = handmatig URL toevoegen.
+
+---
+
+## 7. Wat v1 NIET doet (expliciet geparkeerd)
+- Cross-instance **comments/likes/interactie** (moderatie/abuse-risico).
+- **Herhosting** van remote audio/media (v1 linkt terug; embeddable = v2).
+- Volledige **ActivityPub** (inbox, bezorging, WebFinger, HTTP-signatures) — v2-brug.
+- Centrale **discovery/index**.
+
+---
+
+## 8. Toekomst (v2+, voorbereid maar niet gebouwd)
+- **ActivityPub-brug**: omdat de datavorm al AS 2.0 is, is de stap naar een echte
+  `inbox`/`outbox` + RFC-9421 HTTP Message Signatures + WebFinger incrementeel — dan
+  praat Klonkt ook met Mastodon/fediverse. Premium/optioneel.
+- **Embeddable content**: cross-host players (hergebruik het bestaande embed-player +
+  PlaybackRegistry-patroon), met CSP-uitbreiding per vertrouwde bron.
+- **Hub × cirkels**: een hub-site kan zelf een cirkel hebben (model is er al klaar voor
+  via `circle_links.local_site_id`).
+
+---
+
+## 9. Implementatie-volgorde (incrementeel, elk los testbaar)
+1. Migratie (3 tabellen) + `getTenancy` → `circle` + Beheer-toggle.
+2. Eigen publicatie: keypair-bootstrap + `/.klonkt/actor.json` + `/.klonkt/outbox.json`
+   + `Klonkt-Signature`. (Testbaar: `curl` + signatuur-verify-scriptje.)
+3. `CircleService.sync()` (fetch+verify+cache) + cron + "ververs"-knop.
+4. Beheer → Cirkel (toevoegen/lijst/status).
+5. `/cirkel`-feed (statische kaarten) + home-strook.
+6. Sanitatie/security-hardening + adversariële review (signatuur-spoof, cross-origin
+   object-id, XSS-import, key-rotation).
Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 63edb0d519500782be884d350a3d2a41569553c2)
+++ src/config/database.js	(revision 0b7ebbf536d348524f3dd4438dc3096a9055aac5)
@@ -47,4 +47,6 @@
   // Per-site Prutter toggle: when off, DM endpoints/UI are hidden for that site.
   ensureColumn('sites', 'enable_prutter', 'INTEGER DEFAULT 1');
+  // Cirkels: mag deze site in cirkels van anderen verschijnen (surfacing opt-out).
+  ensureColumn('sites', 'allow_circle', 'INTEGER DEFAULT 1');
 
   // v9 audit additions —————————————————————————————————————————
@@ -106,4 +108,43 @@
   `);
   db.prepare("INSERT OR IGNORE INTO app_settings (key, value) VALUES ('tenancy', 'solo')").run();
+
+  // ── Cirkels (federatie) ─────────────────────────────────────
+  // Decentrale, asymmetrische verbindingen tussen solo-instances.
+  db.exec(`
+    CREATE TABLE IF NOT EXISTS circle_links (
+      id TEXT PRIMARY KEY,
+      local_site_id TEXT NOT NULL,
+      remote_url TEXT NOT NULL,
+      remote_actor_id TEXT,
+      label TEXT,
+      status TEXT DEFAULT 'active',
+      added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+      last_synced DATETIME,
+      last_error TEXT,
+      UNIQUE(local_site_id, remote_url),
+      FOREIGN KEY (local_site_id) REFERENCES sites(id)
+    );
+    CREATE TABLE IF NOT EXISTS remote_actors (
+      id TEXT PRIMARY KEY,
+      url TEXT UNIQUE NOT NULL,
+      name TEXT,
+      summary TEXT,
+      avatar TEXT,
+      public_key TEXT NOT NULL,
+      fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP
+    );
+    CREATE TABLE IF NOT EXISTS remote_posts (
+      id TEXT PRIMARY KEY,
+      actor_id TEXT NOT NULL,
+      published DATETIME,
+      title TEXT,
+      summary TEXT,
+      url TEXT,
+      media_json TEXT,
+      raw_json TEXT,
+      fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+      FOREIGN KEY (actor_id) REFERENCES remote_actors(id)
+    );
+  `);
 }
 
Index: src/routes/admin-circle.js
===================================================================
--- src/routes/admin-circle.js	(revision 0b7ebbf536d348524f3dd4438dc3096a9055aac5)
+++ src/routes/admin-circle.js	(revision 0b7ebbf536d348524f3dd4438dc3096a9055aac5)
@@ -0,0 +1,106 @@
+/**
+ * Admin: Cirkel-beheer (god-only).
+ *   GET  /admin/circle           -> lijst van cirkel-links + status
+ *   POST /admin/circle/add       -> Klonkt-URL toevoegen
+ *   POST /admin/circle/:id/remove
+ *   POST /admin/circle/:id/sync  -> nu verversen (pull + verifieer)
+ *   POST /admin/circle/allow     -> toggle "mag in cirkels van anderen verschijnen"
+ *
+ * Zie docs/cirkels-v1-spec.md §5d.
+ */
+
+import express from 'express';
+import crypto from 'crypto';
+import { renderPage } from '../middleware/render.js';
+import { requireGod } from '../middleware/auth.js';
+import db from '../config/database.js';
+import { getTenancy } from '../services/SettingsService.js';
+import { syncOne } from '../services/CircleService.js';
+
+const router = express.Router();
+
+function primarySite() {
+  return db.prepare('SELECT * FROM sites ORDER BY created_at ASC LIMIT 1').get();
+}
+
+router.get('/', requireGod, (req, res) => {
+  const site = primarySite();
+  const links = site
+    ? db.prepare('SELECT * FROM circle_links WHERE local_site_id = ? ORDER BY added_at DESC').all(site.id)
+    : [];
+  const counts = {};
+  for (const l of links) {
+    counts[l.id] = l.remote_actor_id
+      ? db.prepare('SELECT COUNT(*) AS n FROM remote_posts WHERE actor_id = ?').get(l.remote_actor_id).n
+      : 0;
+  }
+  renderPage(req, res, 'pages/admin-circle', {
+    pageTitle: 'Cirkel',
+    bodyClass: 'on-admin',
+    tenancy: getTenancy(),
+    site,
+    links,
+    counts,
+    allowCircle: site ? site.allow_circle !== 0 : true,
+    success: req.query.success || null,
+    error: req.query.error || null,
+  });
+});
+
+router.post('/add', requireGod, (req, res) => {
+  const site = primarySite();
+  if (!site) return res.redirect('/admin/circle?error=' + encodeURIComponent('Geen site gevonden'));
+  const url = (req.body.remote_url || '').toString().trim().replace(/\/+$/, '');
+  if (!/^https:\/\/[^\s/]+(\/[^\s]*)?$/i.test(url)) {
+    return res.redirect('/admin/circle?error=' + encodeURIComponent('Voer een geldige https-URL in'));
+  }
+  const label = (req.body.label || '').toString().slice(0, 80).trim() || null;
+  try {
+    db.prepare("INSERT INTO circle_links (id, local_site_id, remote_url, label, status) VALUES (?, ?, ?, ?, 'active')")
+      .run(crypto.randomUUID(), site.id, url, label);
+  } catch (e) {
+    return res.redirect('/admin/circle?error=' + encodeURIComponent('Deze site staat al in je cirkel'));
+  }
+  res.redirect('/admin/circle?success=' + encodeURIComponent('Toegevoegd — klik "Verversen" om op te halen'));
+});
+
+router.post('/:id/remove', requireGod, (req, res) => {
+  const link = db.prepare('SELECT * FROM circle_links WHERE id = ?').get(req.params.id);
+  if (link) {
+    db.prepare('DELETE FROM circle_links WHERE id = ?').run(link.id);
+    // Gecachte content opruimen als geen andere link nog naar deze actor wijst.
+    if (link.remote_actor_id) {
+      const other = db.prepare('SELECT 1 FROM circle_links WHERE remote_actor_id = ? LIMIT 1').get(link.remote_actor_id);
+      if (!other) {
+        db.prepare('DELETE FROM remote_posts WHERE actor_id = ?').run(link.remote_actor_id);
+        db.prepare('DELETE FROM remote_actors WHERE id = ?').run(link.remote_actor_id);
+      }
+    }
+  }
+  res.redirect('/admin/circle?success=' + encodeURIComponent('Verwijderd'));
+});
+
+router.post('/:id/sync', requireGod, async (req, res) => {
+  const link = db.prepare('SELECT * FROM circle_links WHERE id = ?').get(req.params.id);
+  if (!link) return res.redirect('/admin/circle?error=' + encodeURIComponent('Niet gevonden'));
+  try {
+    const r = await syncOne(link);
+    res.redirect('/admin/circle?success=' + encodeURIComponent(`Bijgewerkt — ${r.items} posts opgehaald`));
+  } catch (e) {
+    const msg = String((e && e.message) || e).slice(0, 300);
+    db.prepare("UPDATE circle_links SET status='error', last_error=?, last_synced=CURRENT_TIMESTAMP WHERE id=?")
+      .run(msg, link.id);
+    res.redirect('/admin/circle?error=' + encodeURIComponent(msg));
+  }
+});
+
+router.post('/allow', requireGod, (req, res) => {
+  const site = primarySite();
+  if (site) {
+    const v = (req.body.allow_circle === 'on' || req.body.allow_circle === '1') ? 1 : 0;
+    db.prepare('UPDATE sites SET allow_circle = ? WHERE id = ?').run(v, site.id);
+  }
+  res.redirect('/admin/circle?success=' + encodeURIComponent('Opgeslagen'));
+});
+
+export default router;
Index: src/routes/admin-settings.js
===================================================================
--- src/routes/admin-settings.js	(revision 63edb0d519500782be884d350a3d2a41569553c2)
+++ src/routes/admin-settings.js	(revision 0b7ebbf536d348524f3dd4438dc3096a9055aac5)
@@ -88,5 +88,5 @@
 
     if (typeof req.body.tenancy !== 'undefined') {
-      setTenancy(req.body.tenancy === 'hub' ? 'hub' : 'solo');
+      setTenancy(req.body.tenancy); // valideert naar solo | hub | circle
     }
     if (typeof req.body.hub_title !== 'undefined') {
Index: src/routes/circle.js
===================================================================
--- src/routes/circle.js	(revision 0b7ebbf536d348524f3dd4438dc3096a9055aac5)
+++ src/routes/circle.js	(revision 0b7ebbf536d348524f3dd4438dc3096a9055aac5)
@@ -0,0 +1,55 @@
+/**
+ * Publieke Cirkel-feed: /cirkel
+ * Toont de gecachte publieke posts uit je cirkel als statische kaarten.
+ * Alleen actief als tenancy === 'circle' (anders next() -> postsRoutes/404).
+ * Zie docs/cirkels-v1-spec.md §5c.
+ */
+
+import express from 'express';
+import { renderPage } from '../middleware/render.js';
+import db from '../config/database.js';
+import { getTenancy } from '../services/SettingsService.js';
+
+const router = express.Router();
+
+function safeUrl(u) {
+  return typeof u === 'string' && /^https?:\/\//i.test(u) ? u : null;
+}
+function safeJson(s) {
+  try { return s ? JSON.parse(s) : []; } catch { return []; }
+}
+
+router.get('/cirkel', (req, res, next) => {
+  if (getTenancy() !== 'circle') return next();
+
+  const rows = db.prepare(`
+    SELECT p.id, p.published, p.title, p.summary, p.url, p.media_json,
+           a.name AS actor_name, a.url AS actor_url, a.avatar AS actor_avatar
+    FROM remote_posts p
+    JOIN remote_actors a ON a.id = p.actor_id
+    ORDER BY COALESCE(p.published, p.fetched_at) DESC
+    LIMIT 100
+  `).all();
+
+  const posts = rows.map((r) => {
+    const media = safeJson(r.media_json)
+      .map((m) => ({ ...m, url: safeUrl(m.url) }))
+      .filter((m) => m.url);
+    const image = media.find((m) => m.type === 'image') || null;
+    return {
+      id: r.id,
+      title: r.title || '(zonder titel)',
+      summary: r.summary || '',
+      url: safeUrl(r.url),
+      published: r.published,
+      actorName: r.actor_name || 'Onbekend',
+      actorUrl: safeUrl(r.actor_url),
+      actorAvatar: safeUrl(r.actor_avatar),
+      image: image ? image.url : null,
+    };
+  });
+
+  renderPage(req, res, 'pages/circle-feed', { pageTitle: 'Cirkel', posts });
+});
+
+export default router;
Index: src/routes/federation.js
===================================================================
--- src/routes/federation.js	(revision 0b7ebbf536d348524f3dd4438dc3096a9055aac5)
+++ src/routes/federation.js	(revision 0b7ebbf536d348524f3dd4438dc3096a9055aac5)
@@ -0,0 +1,38 @@
+// routes/federation.js — publieke Cirkels-endpoints (v1, publicatie-kant).
+//
+//   GET /.klonkt/actor.json   — ActivityStreams-actor + Ed25519-pubkey
+//   GET /.klonkt/outbox.json  — publieke posts als AS Create-objecten,
+//                               getekend via de Klonkt-Signature-header
+//
+// Site-agnostisch en zonder auth — alleen lezen. Zie docs/cirkels-v1-spec.md.
+
+import express from 'express';
+import { buildActor, buildOutbox, signBody } from '../services/CircleFederation.js';
+import { getTenancy } from '../services/SettingsService.js';
+
+const router = express.Router();
+
+function baseUrl(req) {
+  const b = process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`;
+  return b.replace(/\/+$/, '');
+}
+
+router.get('/.klonkt/actor.json', (req, res) => {
+  // Cirkels = solo-naar-solo; hubs publiceren geen federatie-actor.
+  if (getTenancy() === 'hub') return res.status(404).type('text/plain').send('Niet beschikbaar in hub-modus');
+  const body = JSON.stringify(buildActor(baseUrl(req)), null, 2);
+  res.type('application/activity+json; charset=utf-8');
+  res.set('Cache-Control', 'public, max-age=300');
+  res.send(body);
+});
+
+router.get('/.klonkt/outbox.json', (req, res) => {
+  if (getTenancy() === 'hub') return res.status(404).type('text/plain').send('Niet beschikbaar in hub-modus');
+  const body = JSON.stringify(buildOutbox(baseUrl(req)), null, 2);
+  res.type('application/activity+json; charset=utf-8');
+  res.set('Cache-Control', 'public, max-age=300');
+  res.set('Klonkt-Signature', `ed25519=${signBody(body)}`);
+  res.send(body);
+});
+
+export default router;
Index: src/server.js
===================================================================
--- src/server.js	(revision 63edb0d519500782be884d350a3d2a41569553c2)
+++ src/server.js	(revision 0b7ebbf536d348524f3dd4438dc3096a9055aac5)
@@ -43,4 +43,8 @@
 import artistsRoutes from './routes/artists.js';
 import postsRoutes from './routes/posts.js';
+import federationRoutes from './routes/federation.js';
+import { startCircleSyncLoop } from './services/CircleService.js';
+import adminCircleRoutes from './routes/admin-circle.js';
+import circleRoutes from './routes/circle.js';
 
 if (!process.env.SESSION_SECRET) {
@@ -145,4 +149,7 @@
 initializeDatabase();
 
+// Cirkels: periodieke achtergrond-sync van remote instances (no-op tenzij tenancy='circle').
+startCircleSyncLoop();
+
 // Bundle HTMX: copy from node_modules into our own assets dir so we can serve
 // it locally (no third-party CDN). Idempotent — only copies if size differs.
@@ -165,4 +172,8 @@
 const prutter = new PrutterService(db);
 app.locals.prutter = prutter;
+
+// Cirkels-federatie: publieke, site-agnostische endpoints (/.klonkt/*).
+// Vóór resolveSite/theme — ze hebben geen site-context nodig.
+app.use(federationRoutes);
 
 app.use(resolveSite);
@@ -216,4 +227,5 @@
 app.use('/admin/comments', adminCommentsRoutes);
 app.use('/admin/settings', adminSettingsRoutes);
+app.use('/admin/circle', adminCircleRoutes);
 app.use('/admin', adminRoutes);
 app.use('/prutter', prutterRoutes);
@@ -229,4 +241,5 @@
 app.get('/artiesten', (req, res) => res.redirect(301, req.originalUrl.replace(/^\/artiesten/, '/leden'))); // oude URL -> /leden
 app.use('/', hubRoutes); // hub-overview op '/' (solo: next() -> postsRoutes)
+app.use('/', circleRoutes); // /cirkel-feed (solo/hub: next() -> postsRoutes)
 app.use('/', postsRoutes);
 
Index: src/services/CircleFederation.js
===================================================================
--- src/services/CircleFederation.js	(revision 0b7ebbf536d348524f3dd4438dc3096a9055aac5)
+++ src/services/CircleFederation.js	(revision 0b7ebbf536d348524f3dd4438dc3096a9055aac5)
@@ -0,0 +1,147 @@
+// CircleFederation.js — eigen publicatie-kant van "Cirkels" (v1).
+//
+// Publiceert deze instance als een ActivityStreams-actor met een Ed25519-
+// sleutel, plus een outbox van publieke posts. De outbox wordt getekend zodat
+// consumenten (andere Klonkt-instances) de herkomst kunnen verifiëren.
+//
+// v1 = alleen PUBLICEREN + tekenen. Het pullen/verifiëren van remote cirkels
+// (CircleService.sync) komt in een volgende stap. Zie docs/cirkels-v1-spec.md.
+
+import crypto from 'crypto';
+import db from '../config/database.js';
+import { getSetting, setSetting } from './SettingsService.js';
+
+// ── Sleutelbeheer ─────────────────────────────────────────────
+// Per-instance Ed25519-keypair, eenmalig gegenereerd en in app_settings
+// bewaard. Privé = PKCS8-PEM (nooit serveren). Publiek = SPKI-DER base64
+// (gepubliceerd in de actor; round-trip via createPublicKey).
+function getKeys() {
+  let priv = getSetting('circle_privkey_pem', null);
+  let pub = getSetting('circle_pubkey_der_b64', null);
+  if (!priv || !pub) {
+    const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
+    priv = privateKey.export({ type: 'pkcs8', format: 'pem' });
+    pub = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
+    setSetting('circle_privkey_pem', priv);
+    setSetting('circle_pubkey_der_b64', pub);
+  }
+  return { priv, pub };
+}
+
+export function getPublicKeyB64() {
+  return getKeys().pub;
+}
+
+/** Tekent een exacte body-string met de instance-privésleutel (Ed25519). */
+export function signBody(rawString) {
+  const key = crypto.createPrivateKey(getKeys().priv);
+  return crypto.sign(null, Buffer.from(rawString, 'utf8'), key).toString('base64');
+}
+
+/** Verifieert een body tegen een SPKI-DER-base64 publieke sleutel (voor sync/tests). */
+export function verifyBody(rawString, sigB64, pubDerB64) {
+  try {
+    const key = crypto.createPublicKey({
+      key: Buffer.from(pubDerB64, 'base64'), format: 'der', type: 'spki',
+    });
+    return crypto.verify(null, Buffer.from(rawString, 'utf8'), key, Buffer.from(sigB64, 'base64'));
+  } catch {
+    return false;
+  }
+}
+
+// ── Helpers ───────────────────────────────────────────────────
+function primarySite() {
+  // Solo: de primaire/owner-site (eerst aangemaakt) — zelfde keuze als resolveSite.
+  return db.prepare('SELECT * FROM sites ORDER BY created_at ASC LIMIT 1').get();
+}
+
+function stripHtml(s) {
+  return String(s || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
+}
+
+function iso(d) {
+  const t = d ? new Date(d) : new Date();
+  return isNaN(t.getTime()) ? new Date().toISOString() : t.toISOString();
+}
+
+function abs(base, u) {
+  if (!u) return u;
+  return /^https?:\/\//.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`;
+}
+
+// allow_circle: een site mag in cirkels van anderen verschijnen. v1 koppelt dit
+// aan is_public (aparte expliciete flag volgt in de Beheer-UX-stap).
+function allowsCircle(site) {
+  return !!site && site.is_public !== 0 && site.allow_circle !== 0;
+}
+
+// ── Actor ─────────────────────────────────────────────────────
+export function buildActor(base) {
+  const site = primarySite();
+  const id = `${base}/.klonkt/actor.json`;
+  const icon = site && (site.profile_photo || site.og_image_default);
+  return {
+    '@context': ['https://www.w3.org/ns/activitystreams', 'https://schema.org/'],
+    type: 'Person',
+    id,
+    name: site ? (site.profile_name || site.title || 'Klonkt') : 'Klonkt',
+    summary: site ? (site.profile_bio || site.tagline || site.description || '') : '',
+    url: `${base}/`,
+    ...(icon ? { icon: { type: 'Image', url: abs(base, icon) } } : {}),
+    outbox: `${base}/.klonkt/outbox.json`,
+    publicKey: {
+      id: `${id}#key`,
+      owner: id,
+      algorithm: 'ed25519',
+      publicKeyBase64: getPublicKeyB64(),
+    },
+    klonkt: { version: 1, allowCircle: allowsCircle(site) },
+  };
+}
+
+// ── Outbox ────────────────────────────────────────────────────
+export function buildOutbox(base) {
+  const site = primarySite();
+  const id = `${base}/.klonkt/outbox.json`;
+  const empty = {
+    '@context': 'https://www.w3.org/ns/activitystreams',
+    type: 'OrderedCollection', id, totalItems: 0, orderedItems: [],
+  };
+  if (!allowsCircle(site)) return empty;
+
+  const rows = db.prepare(`
+    SELECT slug, title, excerpt, content, cover_image_url, published_at, created_at, type
+    FROM posts
+    WHERE site_id = ? AND status = 'published'
+      AND (origin_server = 'local' OR origin_server IS NULL)
+    ORDER BY COALESCE(published_at, created_at) DESC
+    LIMIT 50
+  `).all(site.id);
+
+  const orderedItems = rows.map((p) => {
+    const url = `${base}/${p.slug}`;
+    const published = iso(p.published_at || p.created_at);
+    const summary = (p.excerpt || stripHtml(p.content)).slice(0, 500);
+    return {
+      type: 'Create',
+      id: `${url}#create`,
+      published,
+      actor: `${base}/.klonkt/actor.json`,
+      object: {
+        type: p.type === 'audio' ? 'Audio' : 'Article',
+        id: url,
+        name: p.title || '(zonder titel)',
+        summary,
+        url,
+        published,
+        ...(p.cover_image_url ? { image: { type: 'Image', url: abs(base, p.cover_image_url) } } : {}),
+      },
+    };
+  });
+
+  return {
+    '@context': 'https://www.w3.org/ns/activitystreams',
+    type: 'OrderedCollection', id, totalItems: orderedItems.length, orderedItems,
+  };
+}
Index: src/services/CircleService.js
===================================================================
--- src/services/CircleService.js	(revision 0b7ebbf536d348524f3dd4438dc3096a9055aac5)
+++ src/services/CircleService.js	(revision 0b7ebbf536d348524f3dd4438dc3096a9055aac5)
@@ -0,0 +1,171 @@
+// CircleService.js — pull-kant van Cirkels (v1).
+//
+// Haalt per circle_link de remote actor + outbox op, verifieert de Ed25519-
+// handtekening, sanitiseert en cachet publieke posts in remote_actors/remote_posts.
+// Alleen LEZEN van remote; nooit schrijven. Zie docs/cirkels-v1-spec.md §5b.
+
+import db from '../config/database.js';
+import { verifyBody } from './CircleFederation.js';
+import { getTenancy } from './SettingsService.js';
+
+const FETCH_TIMEOUT_MS = 10000;
+const MAX_BODY_BYTES = 1024 * 1024; // 1 MB
+const MAX_ITEMS = 50;
+
+function stripHtml(s) {
+  return String(s || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
+}
+function iso(d) {
+  const t = d ? new Date(d) : null;
+  return t && !isNaN(t.getTime()) ? t.toISOString() : null;
+}
+function originOf(u) {
+  try { return new URL(u).origin; } catch { return null; }
+}
+function baseOf(remoteUrl) {
+  return String(remoteUrl).replace(/\/+$/, '');
+}
+
+// Robuuste, defensieve fetch: alleen https, timeout, body-cap, redirect-follow.
+async function fetchText(url) {
+  if (!/^https:\/\//i.test(url)) throw new Error('alleen https toegestaan');
+  const ac = new AbortController();
+  const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
+  try {
+    const res = await fetch(url, {
+      signal: ac.signal,
+      redirect: 'follow',
+      headers: { Accept: 'application/activity+json, application/json' },
+    });
+    if (!res.ok) throw new Error(`HTTP ${res.status}`);
+    const buf = Buffer.from(await res.arrayBuffer());
+    if (buf.length > MAX_BODY_BYTES) throw new Error('body te groot');
+    return { text: buf.toString('utf8'), headers: res.headers, finalUrl: res.url };
+  } finally {
+    clearTimeout(timer);
+  }
+}
+
+const upsertActor = db.prepare(`
+  INSERT INTO remote_actors (id, url, name, summary, avatar, public_key, fetched_at)
+  VALUES (@id, @url, @name, @summary, @avatar, @public_key, CURRENT_TIMESTAMP)
+  ON CONFLICT(id) DO UPDATE SET
+    url=excluded.url, name=excluded.name, summary=excluded.summary,
+    avatar=excluded.avatar, public_key=excluded.public_key, fetched_at=CURRENT_TIMESTAMP
+`);
+const upsertPost = db.prepare(`
+  INSERT INTO remote_posts (id, actor_id, published, title, summary, url, media_json, raw_json, fetched_at)
+  VALUES (@id, @actor_id, @published, @title, @summary, @url, @media_json, @raw_json, CURRENT_TIMESTAMP)
+  ON CONFLICT(id) DO UPDATE SET
+    published=excluded.published, title=excluded.title, summary=excluded.summary,
+    url=excluded.url, media_json=excluded.media_json, raw_json=excluded.raw_json, fetched_at=CURRENT_TIMESTAMP
+`);
+
+export async function syncOne(link) {
+  const base = baseOf(link.remote_url);
+
+  // 1. Actor ophalen + valideren
+  const actorUrl = `${base}/.klonkt/actor.json`;
+  const a = await fetchText(actorUrl);
+  let actor;
+  try { actor = JSON.parse(a.text); } catch { throw new Error('actor: ongeldige JSON'); }
+  const actorId = actor.id;
+  const pubKey = actor.publicKey && actor.publicKey.publicKeyBase64;
+  if (!actorId || !pubKey) throw new Error('actor mist id/publicKey');
+  if (originOf(actorId) !== originOf(actorUrl)) throw new Error('actor.id heeft andere origin dan de actor-URL');
+
+  // TOFU: een sleutelwissel vereist expliciete herbevestiging (anti-hijack)
+  const existing = db.prepare('SELECT public_key FROM remote_actors WHERE id = ?').get(actorId);
+  if (existing && existing.public_key !== pubKey) {
+    throw new Error('publieke sleutel gewijzigd — herbevestiging vereist (TOFU)');
+  }
+
+  upsertActor.run({
+    id: actorId,
+    url: actor.url || base,
+    name: actor.name || null,
+    summary: actor.summary || null,
+    avatar: (actor.icon && actor.icon.url) || null,
+    public_key: pubKey,
+  });
+
+  // 2. Outbox ophalen + handtekening verifiëren
+  const outboxUrl = actor.outbox || `${base}/.klonkt/outbox.json`;
+  const o = await fetchText(outboxUrl);
+  const sigHeader = o.headers.get('klonkt-signature') || '';
+  const sig = (sigHeader.match(/ed25519=(.+)\s*$/) || [])[1];
+  if (!sig || !verifyBody(o.text, sig, pubKey)) {
+    throw new Error('outbox-handtekening ongeldig of ontbreekt');
+  }
+  let outbox;
+  try { outbox = JSON.parse(o.text); } catch { throw new Error('outbox: ongeldige JSON'); }
+  const items = Array.isArray(outbox.orderedItems) ? outbox.orderedItems.slice(0, MAX_ITEMS) : [];
+
+  // 3. Objecten sanitizen + cachen (same-origin als de actor = anti-impersonatie)
+  const actorOrigin = originOf(actorId);
+  const seen = new Set();
+  for (const it of items) {
+    const obj = it && it.object;
+    if (!obj || !obj.id) continue;
+    if (originOf(obj.id) !== actorOrigin) continue;
+    const media = [];
+    if (obj.image && obj.image.url) media.push({ type: 'image', url: obj.image.url });
+    if (Array.isArray(obj.attachment)) {
+      for (const att of obj.attachment) {
+        if (att && att.url) media.push({ type: String(att.type || 'link').toLowerCase(), url: att.url, name: att.name, duration: att.duration });
+      }
+    }
+    upsertPost.run({
+      id: obj.id,
+      actor_id: actorId,
+      published: iso(obj.published || it.published),
+      title: stripHtml(obj.name).slice(0, 300) || '(zonder titel)',
+      summary: stripHtml(obj.summary || obj.content).slice(0, 1000),
+      url: obj.url || obj.id,
+      media_json: media.length ? JSON.stringify(media) : null,
+      raw_json: JSON.stringify(obj).slice(0, 20000),
+    });
+    seen.add(obj.id);
+  }
+
+  // 4. Pruning: posts die niet meer in de outbox staan opruimen
+  const known = db.prepare('SELECT id FROM remote_posts WHERE actor_id = ?').all(actorId).map((r) => r.id);
+  const stale = known.filter((id) => !seen.has(id));
+  if (stale.length) {
+    const del = db.prepare('DELETE FROM remote_posts WHERE id = ?');
+    db.transaction((ids) => ids.forEach((id) => del.run(id)))(stale);
+  }
+
+  db.prepare(
+    "UPDATE circle_links SET remote_actor_id=?, last_synced=CURRENT_TIMESTAMP, status='active', last_error=NULL WHERE id=?"
+  ).run(actorId, link.id);
+
+  return { ok: true, actorId, items: seen.size, pruned: stale.length };
+}
+
+export async function sync() {
+  if (getTenancy() !== 'circle') return { skipped: 'tenancy != circle' };
+  const links = db.prepare("SELECT * FROM circle_links WHERE status != 'paused'").all();
+  const results = [];
+  for (const link of links) {
+    try {
+      results.push(await syncOne(link));
+    } catch (e) {
+      const msg = String((e && e.message) || e).slice(0, 300);
+      db.prepare("UPDATE circle_links SET status='error', last_error=?, last_synced=CURRENT_TIMESTAMP WHERE id=?")
+        .run(msg, link.id);
+      results.push({ ok: false, link: link.remote_url, error: msg });
+    }
+  }
+  return { synced: results.length, results };
+}
+
+let _timer = null;
+/** Periodieke achtergrond-sync (gated op tenancy='circle' binnen sync()). */
+export function startCircleSyncLoop(intervalMs = 15 * 60 * 1000) {
+  if (_timer) return;
+  const run = () => { sync().catch((e) => console.error('[cirkels] sync-fout:', e.message)); };
+  setTimeout(run, 30 * 1000); // korte delay na boot
+  _timer = setInterval(run, intervalMs);
+  if (_timer.unref) _timer.unref();
+}
Index: src/services/SettingsService.js
===================================================================
--- src/services/SettingsService.js	(revision 63edb0d519500782be884d350a3d2a41569553c2)
+++ src/services/SettingsService.js	(revision 0b7ebbf536d348524f3dd4438dc3096a9055aac5)
@@ -35,8 +35,10 @@
 
 export function getTenancy() {
-  return getSetting('tenancy', 'solo') === 'hub' ? 'hub' : 'solo';
+  const v = getSetting('tenancy', 'solo');
+  return v === 'hub' ? 'hub' : v === 'circle' ? 'circle' : 'solo';
 }
 
 export function setTenancy(mode) {
-  setSetting('tenancy', mode === 'hub' ? 'hub' : 'solo');
+  const m = (mode === 'hub' || mode === 'circle') ? mode : 'solo';
+  setSetting('tenancy', m);
 }
Index: src/views/pages/admin-circle.ejs
===================================================================
--- src/views/pages/admin-circle.ejs	(revision 0b7ebbf536d348524f3dd4438dc3096a9055aac5)
+++ src/views/pages/admin-circle.ejs	(revision 0b7ebbf536d348524f3dd4438dc3096a9055aac5)
@@ -0,0 +1,110 @@
+<div class="container admin-page">
+  <h1>Cirkel</h1>
+  <p><a href="/admin/settings" class="btn">&larr; Instellingen</a></p>
+
+  <% if (success) { %><div class="alert alert-success"><%= success %></div><% } %>
+  <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
+
+  <% if (tenancy !== 'circle') { %>
+    <section class="set-card">
+      <p class="set-help">De modus staat niet op <strong>Cirkels</strong>. Zet 'm aan bij
+        <a href="/admin/settings">Instellingen</a> om je cirkel-feed te tonen op <code>/cirkel</code>.
+        Je kunt hieronder al wel bronnen klaarzetten.</p>
+    </section>
+  <% } %>
+
+  <section class="set-card" style="margin-top:1rem">
+    <h2>Mijn zichtbaarheid</h2>
+    <p class="set-help">Mag jouw site verschijnen in cirkels van anderen? (Toont je publieke
+      posts via <code>/.klonkt/outbox.json</code>.)</p>
+    <form method="post" action="/admin/circle/allow" class="set-form">
+      <label class="set-opt" style="align-items:center">
+        <input type="checkbox" name="allow_circle" value="on" <%= allowCircle ? 'checked' : '' %>>
+        <span><strong>Toon mijn site in cirkels van anderen</strong></span>
+      </label>
+      <button type="submit" class="btn btn-primary">Opslaan</button>
+    </form>
+  </section>
+
+  <section class="set-card" style="margin-top:1rem">
+    <h2>Klonkt-site toevoegen</h2>
+    <p class="set-help">Plak de basis-URL van een andere Klonkt-site. Asymmetrisch: jij toont
+      hen, los van of zij jou tonen.</p>
+    <form method="post" action="/admin/circle/add" class="set-form">
+      <label class="set-field">
+        <span>URL</span>
+        <input type="url" name="remote_url" placeholder="https://artiest.klonkt.com" required>
+      </label>
+      <label class="set-field">
+        <span>Label <small style="font-weight:400">— optioneel</small></span>
+        <input type="text" name="label" maxlength="80" placeholder="bijv. Joost Klein">
+      </label>
+      <button type="submit" class="btn btn-primary">Toevoegen</button>
+    </form>
+  </section>
+
+  <section class="set-card" style="margin-top:1rem">
+    <h2>In mijn cirkel (<%= links.length %>)</h2>
+    <% if (!links.length) { %>
+      <p class="set-help">Nog geen bronnen. Voeg er hierboven een toe.</p>
+    <% } else { %>
+      <ul class="circ-list">
+        <% links.forEach(function(l){ %>
+          <li class="circ-item">
+            <div class="circ-main">
+              <strong><%= l.label || l.remote_url %></strong>
+              <small><%= l.remote_url %></small>
+              <small>
+                <% if (l.status === 'active') { %>
+                  <span class="circ-badge ok">&#10003; actief</span>
+                  <%= counts[l.id] || 0 %> posts
+                  <% if (l.last_synced) { %>&middot; laatst: <%= l.last_synced %><% } %>
+                <% } else if (l.status === 'error') { %>
+                  <span class="circ-badge err">&#9888; fout</span>
+                  <%= l.last_error || '' %>
+                <% } else { %>
+                  <span class="circ-badge">&#9208; gepauzeerd</span>
+                <% } %>
+              </small>
+            </div>
+            <div class="circ-actions">
+              <form method="post" action="/admin/circle/<%= l.id %>/sync" style="display:inline">
+                <button type="submit" class="btn">Verversen</button>
+              </form>
+              <form method="post" action="/admin/circle/<%= l.id %>/remove" style="display:inline"
+                    onsubmit="return confirm('Verwijderen uit je cirkel?')">
+                <button type="submit" class="btn btn-danger">Verwijder</button>
+              </form>
+            </div>
+          </li>
+        <% }); %>
+      </ul>
+    <% } %>
+  </section>
+</div>
+
+<style>
+.admin-page { max-width: 760px; margin: 3rem auto; padding: 0 1rem; }
+.admin-page h1 { font-family: var(--font-display, serif); font-size: 2rem; margin: 0 0 .25rem; }
+.set-card { background: var(--paper-2); border: 1px solid var(--rule); border-radius: 12px; padding: 1.25rem; }
+.set-card h2 { font-family: var(--font-display, serif); font-size: 1.25rem; margin: 0 0 .5rem; }
+.set-help { color: var(--ink-muted); font-size: .9rem; margin: 0 0 1rem; }
+.set-form { display: flex; flex-direction: column; gap: 1rem; }
+.set-opt { display: flex; gap: .75rem; align-items: flex-start; padding: .85rem 1rem; border: 1px solid var(--rule); border-radius: 8px; background: var(--paper); }
+.set-field { display: flex; flex-direction: column; gap: .3rem; }
+.set-field > span { font-size: .8rem; font-weight: 600; color: var(--ink-soft, var(--ink-muted)); }
+.set-field input { width: 100%; box-sizing: border-box; padding: .55rem .7rem; border: 1px solid var(--rule); border-radius: 6px; background: var(--paper); color: var(--ink); font: inherit; }
+.set-form .btn, .circ-actions .btn { align-self: flex-start; }
+.circ-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .6rem; }
+.circ-item { display: flex; justify-content: space-between; gap: 1rem; align-items: center; padding: .75rem 1rem; border: 1px solid var(--rule); border-radius: 8px; background: var(--paper); flex-wrap: wrap; }
+.circ-main { display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
+.circ-main small { color: var(--ink-muted); font-size: .78rem; word-break: break-all; }
+.circ-actions { display: flex; gap: .4rem; flex-shrink: 0; }
+.circ-badge { font-size: .72rem; font-weight: 700; padding: .1rem .45rem; border-radius: 20px; background: var(--rule); }
+.circ-badge.ok { background: #d1fae5; color: #065f46; }
+.circ-badge.err { background: #fee2e2; color: #991b1b; }
+.btn-danger { color: #991b1b; border-color: #fca5a5; }
+.alert { padding: .75rem 1rem; border-radius: 6px; margin-bottom: 1rem; }
+.alert-success { background: #d1fae5; color: #065f46; border: 1px solid #a7f3d0; }
+.alert-error { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
+</style>
Index: src/views/pages/admin-settings.ejs
===================================================================
--- src/views/pages/admin-settings.ejs	(revision 63edb0d519500782be884d350a3d2a41569553c2)
+++ src/views/pages/admin-settings.ejs	(revision 0b7ebbf536d348524f3dd4438dc3096a9055aac5)
@@ -25,4 +25,11 @@
           <strong>Hub</strong> — bedrijfssite met <code>/user/</code>.
           <small>Jij (admin) wijst gebruikers een eigen Klonkt Hub toe. Gesloten cirkel: geen open registratie.</small>
+        </span>
+      </label>
+      <label class="set-opt">
+        <input type="radio" name="tenancy" value="circle" <%= tenancy === 'circle' ? 'checked' : '' %>>
+        <span>
+          <strong>Cirkels</strong> &mdash; solo + federatie.
+          <small>E&eacute;n eigen site die de publieke posts van andere Klonkt-sites toont. Asymmetrisch: jij bepaalt wie in jouw cirkel zit.</small>
         </span>
       </label>
@@ -80,4 +87,15 @@
   </section>
   <% } %>
+
+  <% if (tenancy === 'circle') { %>
+  <section class="set-card" style="margin-top:1rem">
+    <h2>Cirkel</h2>
+    <p class="set-help">
+      Beheer welke andere Klonkt-sites je in je cirkel toont, en of jouw site in
+      cirkels van anderen mag verschijnen.
+    </p>
+    <p><a href="/admin/circle" class="btn btn-primary">Beheer je cirkel &rarr;</a></p>
+  </section>
+  <% } %>
 </div>
 
Index: src/views/pages/circle-feed.ejs
===================================================================
--- src/views/pages/circle-feed.ejs	(revision 0b7ebbf536d348524f3dd4438dc3096a9055aac5)
+++ src/views/pages/circle-feed.ejs	(revision 0b7ebbf536d348524f3dd4438dc3096a9055aac5)
@@ -0,0 +1,65 @@
+<div class="container circle-page">
+  <header class="circle-head">
+    <h1>Cirkel</h1>
+    <p class="circle-sub">Wat er speelt bij de Klonkt-sites in jouw cirkel.</p>
+  </header>
+
+  <% if (!posts.length) { %>
+    <p class="circle-empty">Nog niets in je cirkel. Voeg sites toe via
+      <a href="/admin/circle">Beheer &rsaquo; Cirkel</a> en ververs ze.</p>
+  <% } else { %>
+    <div class="circle-grid">
+      <% posts.forEach(function(p){ %>
+        <article class="circle-card">
+          <% if (p.image) { %>
+            <a class="cc-cover" <% if (p.url) { %>href="<%= p.url %>" target="_blank" rel="noopener"<% } %>>
+              <img loading="lazy" src="<%= p.image %>" alt="">
+            </a>
+          <% } %>
+          <div class="cc-body">
+            <div class="cc-src">
+              <% if (p.actorAvatar) { %><img class="cc-avatar" src="<%= p.actorAvatar %>" alt=""><% } %>
+              <% if (p.actorUrl) { %>
+                <a href="<%= p.actorUrl %>" target="_blank" rel="noopener"><%= p.actorName %></a>
+              <% } else { %><span><%= p.actorName %></span><% } %>
+            </div>
+            <h2 class="cc-title">
+              <% if (p.url) { %><a href="<%= p.url %>" target="_blank" rel="noopener"><%= p.title %></a>
+              <% } else { %><%= p.title %><% } %>
+            </h2>
+            <% if (p.summary) { %><p class="cc-summary"><%= p.summary %></p><% } %>
+            <div class="cc-foot">
+              <% if (p.published) { %><span class="cc-date"><%= p.published.slice(0,10) %></span><% } %>
+              <% if (p.url) { %><a class="cc-link" href="<%= p.url %>" target="_blank" rel="noopener">Lees bij de bron &rarr;</a><% } %>
+            </div>
+          </div>
+        </article>
+      <% }); %>
+    </div>
+  <% } %>
+</div>
+
+<style>
+.circle-page { max-width: 1000px; margin: 2.5rem auto; padding: 0 1rem; }
+.circle-head h1 { font-family: var(--font-display, serif); font-size: 2rem; margin: 0 0 .25rem; }
+.circle-sub { color: var(--ink-muted); margin: 0 0 2rem; }
+.circle-empty { color: var(--ink-muted); }
+.circle-empty a { color: var(--accent); }
+.circle-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.1rem; }
+.circle-card { display: flex; flex-direction: column; border: 1px solid var(--rule); border-radius: 12px; overflow: hidden; background: var(--paper-2); }
+.cc-cover { display: block; aspect-ratio: 16/9; overflow: hidden; }
+.cc-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.cc-body { padding: .9rem 1rem 1rem; display: flex; flex-direction: column; gap: .5rem; }
+.cc-src { display: flex; align-items: center; gap: .45rem; font-size: .8rem; color: var(--ink-muted); }
+.cc-src a { color: var(--ink-muted); text-decoration: none; }
+.cc-src a:hover { color: var(--accent); }
+.cc-avatar { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; }
+.cc-title { font-family: var(--font-display, serif); font-size: 1.15rem; margin: 0; line-height: 1.25; }
+.cc-title a { color: var(--ink); text-decoration: none; }
+.cc-title a:hover { color: var(--accent); }
+.cc-summary { margin: 0; font-size: .9rem; color: var(--ink-soft, var(--ink-muted)); line-height: 1.5;
+  display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
+.cc-foot { display: flex; justify-content: space-between; align-items: center; gap: .5rem; margin-top: auto; padding-top: .35rem; font-size: .78rem; }
+.cc-date { color: var(--ink-muted); }
+.cc-link { color: var(--accent); text-decoration: none; white-space: nowrap; }
+</style>
