Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 6d5ce0cde5b3d2911f42fcf2b4cd343d687c8c3b)
+++ src/routes/activitypub.js	(revision 08ab8adfb2c704f234c751e044723451d5a65e53)
@@ -161,5 +161,5 @@
   const playbackAllowed = embedsAllowed
     && Guardianship.externalPlaybackAllowed(auth.site.external_playback, isWard);
-  const items = AP.getTimeline(auth.site.slug, 60).map((t) => ({
+  const posts = AP.getTimeline(auth.site.slug, 60).map((t) => ({
     id: `${t.id}#create`,
     type: 'Create',
@@ -217,4 +217,45 @@
     },
   }));
+  // The direct notes addressed to this account: a plain DM, a guardian's wave
+  // (§5), a ward's 🛟 help request (§5.2.1). Those are messages, not posts, so
+  // they are not in the timeline; without them the app's Berichten shows only
+  // what you said yourself. Same shape as a post, so one parser handles both.
+  const me = AP.actorId(base, auth.site.slug);
+  const myHandle = (() => { try { return `@${auth.site.slug}@${new URL(base).host}`; } catch { return `@${auth.site.slug}`; } })();
+  const messages = AP.getDirectMessages(auth.site.slug, 60).map((m) => ({
+    id: `${m.object_uri}#create`,
+    type: 'Create',
+    actor: m.actor_uri,
+    published: AP.isoStamp(m.published || m.created_at),
+    object: {
+      id: m.object_uri,
+      type: 'Note',
+      attributedTo: m.actor_uri,
+      content: AP.stripLeadingMentions(m.content),
+      url: m.note_url || undefined,
+      published: AP.isoStamp(m.published || m.created_at),
+      // Addressed to us and to nobody we know of: the other recipients of a
+      // note to several people are not ours to see, so we serve what we know.
+      to: [me],
+      // The Mention is how the client recognises itself as the addressee and
+      // groups the note into a conversation. No FEP-e232 link tags here: a
+      // mention row keeps the resolved quote, not the raw tags.
+      tag: [{ type: 'Mention', href: me, name: myHandle }, ...(AP.timelineEmojis(m.emoji_json) || [])],
+      attachment: AP.timelineAttachments(m.media_json),
+      // FEP-633c: what kind of message this is. The wave is a gentle nudge from
+      // a guardian; the help request is the buoy. Both render differently.
+      'shaer:wave': m.wave ? true : undefined,
+      'shaer:helpRequest': m.help_request ? true : undefined,
+      'shaer:quote': AP.timelineQuote(m.quote_json),
+      'shaer:author': (m.actor_name || m.actor_handle || m.actor_icon) ? {
+        name: m.actor_name || undefined, handle: m.actor_handle || undefined,
+        icon: m.actor_icon || undefined, url: m.actor_url || undefined,
+        emojis: (() => { try { return m.actor_emoji_json ? JSON.parse(m.actor_emoji_json) : undefined; } catch { return undefined; } })(),
+      } : undefined,
+      'shaer:embed': embedsAllowed ? AP.timelineEmbed(m.embed_json, { playback: playbackAllowed }) : undefined,
+    },
+  }));
+  // Newest first over both, so the app can keep treating this as one feed.
+  const items = [...posts, ...messages].sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
   AP.sendAP(res, {
     '@context': AP.AP_CONTEXT,
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 6d5ce0cde5b3d2911f42fcf2b4cd343d687c8c3b)
+++ src/services/ActivityPubService.js	(revision 08ab8adfb2c704f234c751e044723451d5a65e53)
@@ -2734,4 +2734,46 @@
 export function getTimeline(slug, limit, offset) { return tlStmts().list.all(slug, limit || 50, offset || 0); }
 
+/**
+ * The direct notes addressed to this account: a plain DM, a guardian's wave
+ * (§5), a ward's 🛟 help request (§5.2.1). They live in ap_mentions and NOT in
+ * the timeline, because a note addressed to named people is a message and not a
+ * post (belongsInTimeline).
+ *
+ * A client that only reads the timeline therefore sees none of them, which is
+ * exactly what happened to Shaer: Berichten showed your own replies (those come
+ * from your outbox) and nothing that was said to you. The C2S inbox read serves
+ * both, so the app has one door for everything that arrives.
+ *
+ * A public mention from someone you follow is stored in both tables; those are
+ * skipped here and stay a post.
+ */
+export function getDirectMessages(slug, limit) {
+  try {
+    return db.prepare(`
+      SELECT m.object_uri, m.note_url, m.actor_uri, m.actor_name, m.actor_handle, m.actor_icon, m.actor_url,
+             m.content, m.published, m.created_at, m.wave, m.help_request,
+             m.emoji_json, m.actor_emoji_json, m.media_json, m.quote_json, m.embed_json
+      FROM ap_mentions m
+      WHERE m.slug = ?
+        AND NOT EXISTS (SELECT 1 FROM ap_timeline t WHERE t.slug = m.slug AND t.id = m.object_uri)
+      ORDER BY COALESCE(m.published, m.created_at) DESC LIMIT ?`).all(slug, limit || 60);
+  } catch { return []; }
+}
+
+/**
+ * A stored stamp as an ISO instant. SQLite's CURRENT_TIMESTAMP writes
+ * 'YYYY-MM-DD HH:MM:SS' in UTC, which Date.parse reads as LOCAL time; on a
+ * server two hours ahead that dated every message two hours early and put the
+ * conversation in the wrong order. A `published` from the wire is already ISO
+ * and passes through untouched.
+ */
+export function isoStamp(v) {
+  if (!v) return undefined;
+  const s = String(v);
+  if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/.test(s)) return `${s.replace(' ', 'T')}Z`;
+  const t = Date.parse(s);
+  return Number.isFinite(t) ? new Date(t).toISOString() : undefined;
+}
+
 // Inbox C2S read: a timeline row's media_json ([{url, type}], written on the
 // inbound Create) → AS2 `attachment` array, so a client (Shaer) can render a
@@ -3927,10 +3969,10 @@
 
 export default {
-  AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId,
+  AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId, stripLeadingMentions,
   buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
   followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
   getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
   listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
-  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
+  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
   acceptGatedFollow, rejectGatedFollow, isWardGuardian, sendFollowDecision,
   parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
Index: test/c2s-messages.test.js
===================================================================
--- test/c2s-messages.test.js	(revision 08ab8adfb2c704f234c751e044723451d5a65e53)
+++ test/c2s-messages.test.js	(revision 08ab8adfb2c704f234c751e044723451d5a65e53)
@@ -0,0 +1,102 @@
+// What the app reads is what the app can show.
+//
+// Shaer builds Berichten, gesprekken and the help-escalation list from one
+// source: the C2S inbox read. That read served only the timeline, and a direct
+// note (a DM, a guardian's wave, a ward's 🛟) is not in the timeline, because a
+// note addressed to named people is a message and not a post.
+//
+// The result was an app that showed your own replies and nothing that was said
+// to you. These tests hold the two tables apart in the database and together in
+// the read.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = (await import('../src/services/ActivityPubService.js')).default;
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@t', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)').run('s1', 'kid', 'kid', 'u1');
+
+const mention = (uri, { wave = 0, help = 0, content = '<p>hoi</p>', published = null } = {}) =>
+  db.prepare(`INSERT INTO ap_mentions (slug, object_uri, actor_uri, actor_name, actor_handle, content, published, wave, help_request)
+              VALUES ('kid', ?, 'https://oma.test/u/oma', 'Oma', '@oma@oma.test', ?, ?, ?, ?)`)
+    .run(uri, content, published, wave, help);
+
+mention('https://oma.test/n/1', { wave: 1, content: '<p><a href="x">@kid@test.example</a> 👋</p>' });
+mention('https://oma.test/n/2', { help: 1 });
+mention('https://oma.test/n/3');
+// A public mention from someone you follow is stored in BOTH tables. It is a
+// post, and it must not turn up twice.
+mention('https://vriend.test/n/9');
+db.prepare(`INSERT INTO ap_timeline (id, slug, author_uri, content) VALUES (?, 'kid', 'https://vriend.test/u/v', '<p>publiek</p>')`)
+  .run('https://vriend.test/n/9');
+
+test('a direct note is not a timeline row', () => {
+  // The premise. If this ever flips, the app gets its messages back by
+  // accident and the Krant fills up with DMs again (d9ad6c5).
+  assert.equal(AP.getTimeline('kid', 50).length, 1, 'only the public post is a post');
+});
+
+test('the direct notes come out of the message read', () => {
+  const msgs = AP.getDirectMessages('kid', 60);
+  const ids = msgs.map((m) => m.object_uri).sort();
+  assert.deepEqual(ids, ['https://oma.test/n/1', 'https://oma.test/n/2', 'https://oma.test/n/3'],
+    'the three messages, and NOT the note that is already a post');
+  assert.equal(msgs.find((m) => m.object_uri.endsWith('/1')).wave, 1, 'the wave is marked as one');
+  assert.equal(msgs.find((m) => m.object_uri.endsWith('/2')).help_request, 1, 'and the buoy as a buoy');
+});
+
+test('a stored stamp becomes an instant, not a two-hour lie', () => {
+  // SQLite writes CURRENT_TIMESTAMP as 'YYYY-MM-DD HH:MM:SS' in UTC, which
+  // Date.parse reads as local time. On this server that is off by an hour or
+  // two, which is enough to scramble the order of a conversation.
+  assert.equal(AP.isoStamp('2026-07-29 08:15:00'), '2026-07-29T08:15:00Z');
+  assert.equal(AP.isoStamp('2026-07-29T08:15:00.000Z'), '2026-07-29T08:15:00.000Z');
+  assert.equal(AP.isoStamp(null), undefined);
+  assert.equal(AP.isoStamp('nonsense'), undefined);
+});
+
+test('the inbox read serves posts and messages in one collection', async (t) => {
+  // Straight through the route, because the mapping is where the app-facing
+  // shape is decided: the addressing that turns a note into a conversation and
+  // the flag that turns one into a wave.
+  const crypto = await import('crypto');
+  const express = (await import('express')).default;
+  const routes = (await import('../src/routes/activitypub.js')).default;
+
+  const bearer = 'test-token-' + 'a'.repeat(24);
+  const hash = crypto.createHash('sha256').update(bearer).digest('base64url');
+  db.prepare('INSERT INTO oauth_tokens (token_hash, client_id, user_id, site_slug, scope) VALUES (?,?,?,?,?)')
+    .run(hash, 'test-client', 'u1', 'kid', 'read write');
+
+  const app = express();
+  app.use(routes);
+  const server = app.listen(0);
+  t.after(() => server.close());
+  await new Promise((r) => server.once('listening', r));
+  const url = `http://127.0.0.1:${server.address().port}/ap/users/kid/inbox`;
+  const doc = await (await fetch(url, { headers: { Authorization: `Bearer ${bearer}` } })).json();
+
+  const byId = Object.fromEntries(doc.orderedItems.map((i) => [i.object.id, i.object]));
+  assert.equal(doc.orderedItems.length, 4, 'one post and three messages, the double counted once');
+
+  const wave = byId['https://oma.test/n/1'];
+  assert.ok(wave, 'the wave is in the read at all (this is the whole bug)');
+  assert.equal(wave['shaer:wave'], true, 'and it says it is a wave');
+  assert.deepEqual(wave.to, ['https://test.example/ap/users/kid'],
+    'addressed to me, which is what makes it a conversation instead of a loose note');
+  assert.ok((wave.tag || []).some((x) => x.type === 'Mention' && x.href === 'https://test.example/ap/users/kid'),
+    'with a Mention the client recognises itself in');
+  assert.equal(wave['shaer:author'].name, 'Oma', 'and a byline to show');
+  assert.ok(!/@kid@test\.example/.test(wave.content), 'the leading @mention is stripped, like Berichten on the web');
+
+  assert.equal(byId['https://oma.test/n/2']['shaer:helpRequest'], true, 'the buoy stays a buoy');
+  assert.equal(byId['https://oma.test/n/3']['shaer:wave'], undefined, 'an ordinary DM claims to be neither');
+  assert.equal(byId['https://vriend.test/n/9']['shaer:wave'], undefined, 'and the public post is served once, as a post');
+});
