Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision af5407f37e07abe61941a76c0d63d4177df70ca6)
+++ src/config/database.js	(revision b300682efae63352c2693b70b56e27061543450b)
@@ -106,4 +106,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/federation.js
===================================================================
--- src/routes/federation.js	(revision b300682efae63352c2693b70b56e27061543450b)
+++ src/routes/federation.js	(revision b300682efae63352c2693b70b56e27061543450b)
@@ -0,0 +1,34 @@
+// 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';
+
+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) => {
+  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) => {
+  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 af5407f37e07abe61941a76c0d63d4177df70ca6)
+++ src/server.js	(revision b300682efae63352c2693b70b56e27061543450b)
@@ -43,4 +43,5 @@
 import artistsRoutes from './routes/artists.js';
 import postsRoutes from './routes/posts.js';
+import federationRoutes from './routes/federation.js';
 
 if (!process.env.SESSION_SECRET) {
@@ -166,4 +167,8 @@
 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);
 app.use(loadAudioTracks);
Index: src/services/CircleFederation.js
===================================================================
--- src/services/CircleFederation.js	(revision b300682efae63352c2693b70b56e27061543450b)
+++ src/services/CircleFederation.js	(revision b300682efae63352c2693b70b56e27061543450b)
@@ -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;
+}
+
+// ── 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/SettingsService.js
===================================================================
--- src/services/SettingsService.js	(revision af5407f37e07abe61941a76c0d63d4177df70ca6)
+++ src/services/SettingsService.js	(revision b300682efae63352c2693b70b56e27061543450b)
@@ -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);
 }
