Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision c1f5c23957130368f24ac295ed6f9963cc925c18)
+++ src/config/database.js	(revision 05665bc80c56dec54024a98cd59b2031a6f68c17)
@@ -38,4 +38,14 @@
   // SQLite throws if the column already exists; we swallow that.
   ensureColumn('sites', 'enable_audio_player', 'INTEGER DEFAULT 1');
+  // Guardian 2: losse guardians. Een guardian-only account is user + minimale
+  // site (alleen de actor telt); de vlag houdt CMS/listings erbuiten.
+  ensureColumn('sites', 'guardian_only', 'INTEGER DEFAULT 0');
+  db.exec(`CREATE TABLE IF NOT EXISTS ap_guardian_invites (
+    token TEXT PRIMARY KEY,
+    created_by TEXT NOT NULL,
+    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+    used_by TEXT,
+    used_at TEXT
+  )`);
   ensureColumn('sites', 'profile_photo', 'TEXT');
   ensureColumn('audio_tracks', 'cover_url', 'TEXT');
Index: src/routes/guardian2.js
===================================================================
--- src/routes/guardian2.js	(revision c1f5c23957130368f24ac295ed6f9963cc925c18)
+++ src/routes/guardian2.js	(revision 05665bc80c56dec54024a98cd59b2031a6f68c17)
@@ -10,4 +10,6 @@
  */
 import express from 'express';
+import crypto from 'crypto';
+import bcrypt from 'bcryptjs';
 import path from 'path';
 import { fileURLToPath } from 'url';
@@ -181,3 +183,65 @@
 });
 
+// ── Losse guardians (Guardian 2): uitnodigen en aansluiten ───────────────
+// De familie nodigt oma uit; zij kiest naam + wachtwoord en heeft daarmee een
+// guardian-only account: user + minimale site (guardian_only=1). Alles wat al
+// per slug werkt (actor, inbox, offers, push, deze PWA) werkt dan meteen.
+
+router.post('/invite', requireAuth, (req, res) => {
+  const token = crypto.randomBytes(16).toString('base64url');
+  db.prepare('INSERT INTO ap_guardian_invites (token, created_by) VALUES (?,?)')
+    .run(token, req.session.user.id);
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  const url = `${base}/guardian2/join/${token}`;
+  res.send(`<!doctype html><meta charset="utf-8"><body style="font-family:sans-serif;max-width:480px;margin:40px auto">
+    <h2>Invite a guardian</h2>
+    <p>Share this link. It lets one person create a guardian account here:</p>
+    <p><a href="${url}">${url}</a></p>
+    <p><a href="/guardian2">Back</a></p></body>`);
+});
+
+function joinForm(token, error) {
+  return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+  <body style="font-family:sans-serif;max-width:420px;margin:40px auto">
+  <h2>Become a guardian</h2>
+  <p>Watch over someone you care about. Pick a name and a password; that is all.</p>
+  ${error ? `<p style="color:#b00">${error}</p>` : ''}
+  <form method="post" action="/guardian2/join/${token}">
+    <p><input name="name" placeholder="your name (grandma)" required pattern="[a-z0-9_-]{1,32}"
+       style="width:100%;padding:10px" autocapitalize="none"></p>
+    <p><input name="password" type="password" placeholder="password" required minlength="8"
+       style="width:100%;padding:10px"></p>
+    <p><button style="width:100%;padding:12px">Create my guardian account</button></p>
+  </form></body>`;
+}
+
+router.get('/join/:token', (req, res) => {
+  const inv = db.prepare('SELECT * FROM ap_guardian_invites WHERE token = ? AND used_at IS NULL')
+    .get(req.params.token);
+  if (!inv) return res.status(404).send('This invite is no longer valid.');
+  res.send(joinForm(req.params.token));
+});
+
+router.post('/join/:token', express.urlencoded({ extended: false }), (req, res) => {
+  const inv = db.prepare('SELECT * FROM ap_guardian_invites WHERE token = ? AND used_at IS NULL')
+    .get(req.params.token);
+  if (!inv) return res.status(404).send('This invite is no longer valid.');
+  const name = String(req.body.name || '').trim().toLowerCase();
+  const password = String(req.body.password || '');
+  if (!/^[a-z0-9_-]{1,32}$/.test(name)) return res.status(400).send(joinForm(req.params.token, 'Only lowercase letters, digits, - and _.'));
+  if (password.length < 8) return res.status(400).send(joinForm(req.params.token, 'Password: at least 8 characters.'));
+  if (db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(name) || db.prepare('SELECT 1 FROM users WHERE username = ?').get(name)) {
+    return res.status(409).send(joinForm(req.params.token, 'That name is taken, pick another.'));
+  }
+  const userId = crypto.randomUUID();
+  db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+    .run(userId, name, `${name}@guardian.invalid`, bcrypt.hashSync(password, 10), 'member');
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, guardian_only) VALUES (?,?,?,?,0,1)')
+    .run(crypto.randomUUID(), name, name, userId);
+  db.prepare('UPDATE ap_guardian_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE token = ?')
+    .run(userId, req.params.token);
+  req.session.user = { id: userId, username: name, role: 'member' };
+  res.redirect('/guardian2');
+});
+
 export default router;
Index: src/views/pages/guardian2.ejs
===================================================================
--- src/views/pages/guardian2.ejs	(revision c1f5c23957130368f24ac295ed6f9963cc925c18)
+++ src/views/pages/guardian2.ejs	(revision 05665bc80c56dec54024a98cd59b2031a6f68c17)
@@ -26,5 +26,8 @@
     <span class="g-me" title="<%= t('guardian.acting_as') %>">@<%= state.site %></span>
     <% } %>
-  </header>
+    <form method="post" action="/guardian2/invite" style="display:inline">
+    <button class="quiet small" type="submit">Invite a guardian</button>
+  </form>
+</header>
 
   <main>
