Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 2d13bf344210a073c6e7bb89dfb18d83b2ff2755)
+++ src/routes/activitypub.js	(revision 03583f653d8bd61e579c509e031b9e6d608943f1)
@@ -704,4 +704,33 @@
 
 // ── Replies collection ── lets remote servers fetch a post's whole thread.
+// ── De thread onder een post (shaer-tqz): ophalen, niet bewaren ────
+//
+// Bearer-only: dit is de eigen app van deze account die vraagt, nooit een
+// vreemde. Klonkt doet de ondertekende GET die de app zelf niet kan (de
+// sleutel staat hier), loopt één pagina van de replies-collectie af en geeft
+// genormaliseerde notes terug. Er wordt NIETS opgeslagen; zie getThread.
+//
+// Voor een ward geldt de veiligste stand tot shaer-vw4 beslist is: alleen
+// antwoorden uit de kring die de guardians al kennen, en shaer:hidden telt wat
+// er buiten viel. De telling staat er zodat de UI eerlijk kan zijn -- OF hij
+// getoond wordt is onderdeel van datzelfde besluit.
+router.get('/ap/users/:slug/thread', async (req, res) => {
+  const auth = OAuth.verifyBearer(req.headers.authorization);
+  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
+  const objectUri = String(req.query.object || '');
+  if (!/^https:\/\//i.test(objectUri)) return res.status(400).json({ error: 'object must be an https URI' });
+  const isWard = (() => { try { return Guardianship.listGuardians(auth.site.slug).length > 0; } catch { return false; } })();
+  const uit = await AP.getThread(auth.site.slug, objectUri, { isWard });
+  if (!uit.found) return res.status(404).json({ error: 'note not reachable' });
+  AP.sendAP(res, {
+    '@context': AP.AP_CONTEXT,
+    id: `${baseUrl(req)}/ap/users/${encodeURIComponent(auth.site.slug)}/thread?object=${encodeURIComponent(objectUri)}`,
+    type: 'OrderedCollection',
+    totalItems: uit.notes.length,
+    orderedItems: uit.notes,
+    'shaer:hidden': uit.hidden || undefined,
+  }, 'private, no-store');
+});
+
 router.get('/ap/notes/:id/replies', (req, res) => {
   const base = baseUrl(req);
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 2d13bf344210a073c6e7bb89dfb18d83b2ff2755)
+++ src/services/ActivityPubService.js	(revision 03583f653d8bd61e579c509e031b9e6d608943f1)
@@ -3347,4 +3347,131 @@
 }
 
+// ── De thread onder een post (shaer-tqz) ───────────────────────────
+//
+// Klonkt is hier een TOLK, geen archief (Barts besluit, 7-8): de antwoorden
+// worden opgehaald op het moment dat iemand kijkt en daarna weer vergeten.
+// Geen tabel, geen migratie -- wie replies bewaart van elke post die iemand
+// tegenkomt, laat de omvang van zijn database bepalen door surfgedrag. Dat is
+// de AFWIJKING, niet de norm: Mastodon serveert /context uit zijn eigen
+// database, en dat verdient zich daar terug omdat honderden mensen de cache
+// delen. Een Klonkt-instance is de server van één persoon.
+//
+// Waarom dit niet in de app kan: de replies-collectie van een vreemde server
+// eist in secure mode een ONDERTEKEND verzoek, en de sleutel staat hier en kan
+// hier niet weg. Voor de opgaande inReplyTo-keten komt de app weg met een
+// ongetekende GET (mist er een, jammer); voor een thread van dertig is "de
+// helft doet het niet" geen resultaat.
+//
+// Je krijgt hier NOOIT de hele thread: een replies-collectie bevat alleen wat
+// die ene server gezien heeft. De UI hoort "wat de bron weet" te tonen en geen
+// volledigheid te suggereren.
+// NIET hetzelfde als maybeCrawlThread verderop: die kruipt de thread onder je
+// EIGEN posts af en bewaart de antwoorden in ap_interactions (dat zijn de
+// jouwe, die horen te blijven). Dit hier is voor een post van een ANDER die je
+// tegenkomt, en bewaart niets.
+const THREAD_VIEW_LIMIT = 30;
+const THREAD_VIEW_TTL_MS = 120_000;
+const THREAD_VIEW_CACHE_MAX = 200;
+const threadViewCache = new Map();   // `${slug}|${uri}` -> { at, out } -- geheugen, weg bij herstart
+
+/** Eén pagina items uit een AS2-collectie, welke spelling hij ook koos. */
+function collectionItems(coll) {
+  if (!coll || typeof coll !== 'object') return [];
+  const arr = coll.orderedItems || coll.items;
+  return Array.isArray(arr) ? arr : [];
+}
+
+/**
+ * De directe antwoorden op één note, genormaliseerd voor de C2S-lezer.
+ *
+ * `circle` is de wachtwoord-vraag van shaer-vw4 in zijn veiligste stand: is de
+ * lezer een ward, dan komen alleen antwoorden door van accounts die de
+ * guardians al kennen (gevolgd of volgend). Wat er buiten valt wordt GETELD en
+ * als aantal teruggegeven, nooit stil weggelaten -- maar het besluit of dat
+ * aantal getoond wordt, en of dit de blijvende regel is, ligt bij Bart en
+ * Robin (shaer-vw4). Geblokkeerde actors zijn een andere categorie: die
+ * verdwijnen zonder telling, een blokkade is onzichtbaar.
+ */
+export async function getThread(slug, objectUri, { isWard = false } = {}) {
+  const key = `${slug}|${objectUri}`;
+  const hit = threadViewCache.get(key);
+  if (hit && Date.now() - hit.at < THREAD_VIEW_TTL_MS) return hit.out;
+
+  const get = (u) => localNoteObject(u, slug) || signedGetJson(slug, u);
+  const note = await get(objectUri);
+  const repliesRef = note && note.replies;
+  let coll = null;
+  if (typeof repliesRef === 'string') coll = await signedGetJson(slug, repliesRef);
+  else if (repliesRef && typeof repliesRef === 'object') {
+    coll = collectionItems(repliesRef).length || repliesRef.first ? repliesRef
+      : (repliesRef.id ? await signedGetJson(slug, repliesRef.id) : repliesRef);
+  }
+  // ÉÉN pagina, met opzet: de eerste. Wie meer wil moet eerst kunnen zeggen
+  // waarom dertig directe antwoorden niet genoeg context is voor een gesprek.
+  let items = collectionItems(coll);
+  if (!items.length && coll && coll.first) {
+    const first = typeof coll.first === 'string' ? await signedGetJson(slug, coll.first) : coll.first;
+    items = collectionItems(first);
+  }
+  items = items.slice(0, THREAD_VIEW_LIMIT);
+
+  // Alles tegelijk in plaats van om de beurt: dertig vreemde servers na elkaar
+  // afwachten is een halve minuut kijken naar een spinner.
+  const objs = await Promise.all(items.map(async (it) => {
+    const o = typeof it === 'string' ? await get(it) : (it && it.object && typeof it.object === 'object' ? it.object : it);
+    return (o && o.id && o.attributedTo) ? o : null;
+  }));
+
+  const circle = isWard ? (() => {
+    const s = new Set();
+    try { for (const r of db.prepare("SELECT actor_uri FROM ap_following WHERE slug = ? AND status = 'accepted'").all(slug)) s.add(r.actor_uri); } catch { /* geen tabel */ }
+    try { for (const r of db.prepare('SELECT actor_uri FROM ap_followers WHERE slug = ?').all(slug)) s.add(r.actor_uri); } catch { /* geen tabel */ }
+    return s;
+  })() : null;
+
+  let hidden = 0;
+  const kept = [];
+  for (const o of objs) {
+    if (!o) continue;
+    const actorUri = actorUriOf(o.attributedTo);
+    if (!actorUri || isBlockedAny(actorUri)) continue;   // een blokkade telt niet mee
+    if (circle && !circle.has(actorUri)) { hidden += 1; continue; }
+    kept.push({ o, actorUri });
+  }
+
+  // Bylines: één fetch per unieke auteur, niet één per antwoord.
+  const authors = new Map();
+  await Promise.all([...new Set(kept.map((k) => k.actorUri))].map(async (uri) => {
+    authors.set(uri, localActorObject(uri) || await signedGetJson(slug, uri).catch(() => null));
+  }));
+
+  const notes = kept.map(({ o, actorUri }) => ({
+    id: o.id,
+    type: 'Note',
+    attributedTo: actorUri,
+    inReplyTo: (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id)) || objectUri,
+    content: HtmlSanitizerService.sanitize(String(o.content || '').slice(0, 50_000)),
+    url: safeUrl(typeof o.url === 'string' ? o.url : (o.url && o.url.href)) || undefined,
+    published: typeof o.published === 'string' ? o.published : undefined,
+    sensitive: !!o.sensitive,
+    summary: typeof o.summary === 'string' ? o.summary.slice(0, 500) : undefined,
+    attachment: (() => {
+      const arr = Array.isArray(o.attachment) ? o.attachment : (o.attachment ? [o.attachment] : []);
+      const out = arr.map((a) => ({ type: 'Document', mediaType: (a && a.mediaType) || undefined, url: safeUrl(a && a.url), name: (a && typeof a.name === 'string') ? a.name.slice(0, 1500) : undefined }))
+        .filter((a) => a.url);
+      return out.length ? out.slice(0, 8) : undefined;
+    })(),
+    'shaer:author': actorInfo(authors.get(actorUri), actorUri),
+  })).sort((a, b) => String(a.published || '').localeCompare(String(b.published || '')));
+
+  const out = { notes, hidden, found: !!note };
+  threadViewCache.set(key, { at: Date.now(), out });
+  if (threadViewCache.size > THREAD_VIEW_CACHE_MAX) {
+    const oldest = [...threadViewCache.entries()].sort((a, b) => a[1].at - b[1].at)[0];
+    if (oldest) threadViewCache.delete(oldest[0]);
+  }
+  return out;
+}
+
 export async function resolveRemoteNote(url, opts = {}) {
   if (!/^https?:\/\//i.test(String(url || ''))) return null;
@@ -5549,5 +5676,5 @@
   getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
   deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
-  getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
+  getReplyUris, getThread, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
   linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
   noteVisibility, belongsInTimeline, playerUrlFor, isRejectedObject, rejectInteraction, interactionReportTarget,
Index: test/thread.test.js
===================================================================
--- test/thread.test.js	(revision 03583f653d8bd61e579c509e031b9e6d608943f1)
+++ test/thread.test.js	(revision 03583f653d8bd61e579c509e031b9e6d608943f1)
@@ -0,0 +1,101 @@
+// De thread onder een post (shaer-tqz): ophalen, normaliseren, niet bewaren.
+//
+// De vier dingen die hier stil kunnen breken en die een gebruiker allemaal
+// anders voelt: een antwoord dat niet doorkomt (te streng), een vreemde die
+// bij een ward doorkomt (te los), HTML die ongeschoond doorreist (gevaarlijk),
+// en een geblokkeerde die meegeteld wordt in shaer:hidden (een blokkade hoort
+// onzichtbaar te zijn, ook als getal).
+//
+// In-memory SQLite, gestubde fetch op TEST-NET-3. Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'kind', 'Kind', 'u1');
+
+const BRON = 'https://203.0.113.10/notes/post-1';
+const TANTE = 'https://203.0.113.20/users/tante';
+const VREEMDE = 'https://203.0.113.30/users/vreemde';
+const GEBLOKT = 'https://203.0.113.40/users/naar';
+
+// De tante is gevolgd (accepted): zij zit in de kring van de guardians.
+db.prepare("INSERT INTO ap_following (slug, actor_uri, status) VALUES ('kind', ?, 'accepted')").run(TANTE);
+db.prepare("INSERT INTO ap_blocks (slug, kind, target) VALUES ('kind', 'actor', ?)").run(GEBLOKT);
+
+const actorDoc = (id, naam) => ({ id, type: 'Person', preferredUsername: naam, name: naam, inbox: `${id}/inbox` });
+const reply = (n, actor, content, published) => ({
+  id: `${BRON}/replies/${n}`, type: 'Note', attributedTo: actor,
+  inReplyTo: BRON, content, published,
+});
+
+const antwoorden = [
+  reply(1, VREEMDE, '<p>Hoi! <script>alert(1)</script></p>', '2026-08-01T10:00:00Z'),
+  reply(2, TANTE, '<p>Dag lieverd</p>', '2026-08-01T09:00:00Z'),
+  reply(3, GEBLOKT, '<p>naar bericht</p>', '2026-08-01T11:00:00Z'),
+];
+
+globalThis.fetch = async (url) => {
+  const u = String(url);
+  const json = (o) => new Response(JSON.stringify(o), { status: 200, headers: { 'content-type': 'application/activity+json' } });
+  if (u === BRON) return json({ id: BRON, type: 'Note', attributedTo: TANTE, content: '<p>de post</p>', replies: `${BRON}/replies` });
+  if (u === `${BRON}/replies`) return json({ id: `${BRON}/replies`, type: 'Collection', first: `${BRON}/replies?page=1` });
+  if (u === `${BRON}/replies?page=1`) return json({ type: 'CollectionPage', orderedItems: antwoorden });
+  if (u === TANTE) return json(actorDoc(TANTE, 'tante'));
+  if (u === VREEMDE) return json(actorDoc(VREEMDE, 'vreemde'));
+  if (u === GEBLOKT) return json(actorDoc(GEBLOKT, 'naar'));
+  return new Response('not found', { status: 404 });
+};
+
+test('een gewone lezer krijgt alles behalve de geblokkeerde, oudste eerst, geschoond', async () => {
+  const uit = await AP.getThread('kind', BRON, { isWard: false });
+  assert.equal(uit.found, true);
+  assert.equal(uit.notes.length, 2);
+  // Oudste eerst: een gesprek lees je van boven naar beneden.
+  assert.equal(uit.notes[0]['shaer:author'].name, 'tante');
+  assert.equal(uit.notes[1]['shaer:author'].name, 'vreemde');
+  // De sanitizer heeft het script eruit gehaald, de tekst mag blijven.
+  assert.ok(!uit.notes[1].content.includes('<script'), 'script weggeschoond');
+  assert.ok(uit.notes[1].content.includes('Hoi!'));
+  // Een blokkade is onzichtbaar: niet in de lijst en NIET in de telling.
+  assert.equal(uit.hidden, 0);
+  assert.ok(uit.notes.every((n) => n.attributedTo !== GEBLOKT));
+});
+
+test('een ward ziet alleen de kring van de guardians, de rest wordt geteld', async () => {
+  // Eigen cache-sleutel per (slug, uri) zou hier de vorige uitkomst hergeven;
+  // een andere slug dwingt een verse opbouw af zonder aan de cache te morrelen.
+  db.prepare("INSERT INTO sites (id, slug, title, owner_id) VALUES ('s2', 'pupil', 'Pupil', 'u1')").run();
+  db.prepare("INSERT INTO ap_following (slug, actor_uri, status) VALUES ('pupil', ?, 'accepted')").run(TANTE);
+  db.prepare("INSERT INTO ap_blocks (slug, kind, target) VALUES ('pupil', 'actor', ?)").run(GEBLOKT);
+  const uit = await AP.getThread('pupil', BRON, { isWard: true });
+  assert.equal(uit.notes.length, 1);
+  assert.equal(uit.notes[0]['shaer:author'].name, 'tante');
+  // De vreemde is er, en dat mag gezegd: geteld, niet stil weggelaten.
+  assert.equal(uit.hidden, 1);
+});
+
+test('een onbereikbare note zegt dat, in plaats van een lege thread te veinzen', async () => {
+  const uit = await AP.getThread('kind', 'https://203.0.113.99/weg', { isWard: false });
+  assert.equal(uit.found, false);
+  assert.equal(uit.notes.length, 0);
+});
+
+test('de tweede lezing komt uit het geheugen, niet van het netwerk', async () => {
+  let calls = 0;
+  const vorige = globalThis.fetch;
+  globalThis.fetch = async (...a) => { calls += 1; return vorige(...a); };
+  const uit = await AP.getThread('kind', BRON, { isWard: false });
+  assert.equal(uit.notes.length, 2);
+  assert.equal(calls, 0, 'alles uit de cache, nul fetches');
+  globalThis.fetch = vorige;
+});
