Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision f1c50f9e8909d1873e736e05e5b2cb0c167154ba)
+++ src/config/database.js	(revision 97bacf2bd1fa5c118355c94e36492a020d61d024)
@@ -547,4 +547,5 @@
   ensureColumn('ap_timeline', 'nsfw', 'INTEGER DEFAULT 0');  // remote sensitive post → blur in the Cirkel
   ensureColumn('ap_timeline', 'cw', 'TEXT');                 // remote content-warning text
+  ensureColumn('ap_timeline', 'emoji_json', 'TEXT');         // FEP-9098 custom emoji Emoji tags from the inbound note, served back as `tag`
   ensureColumn('ap_timeline', 'reblog_name', 'TEXT');        // a followed account boosted this → "X boosted"
   ensureColumn('ap_timeline', 'reblog_handle', 'TEXT');      //   the booster's @handle
Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision f1c50f9e8909d1873e736e05e5b2cb0c167154ba)
+++ src/routes/activitypub.js	(revision 97bacf2bd1fa5c118355c94e36492a020d61d024)
@@ -162,4 +162,7 @@
       // client renders their images/audio like own outbox posts.
       attachment: AP.timelineAttachments(t.media_json),
+      // FEP-9098 custom emojis: the note's Emoji tags, so the client can
+      // render :shortcode: as an image instead of literal text.
+      tag: AP.timelineEmojis(t.emoji_json),
     },
   }));
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision f1c50f9e8909d1873e736e05e5b2cb0c167154ba)
+++ src/services/ActivityPubService.js	(revision 97bacf2bd1fa5c118355c94e36492a020d61d024)
@@ -1537,4 +1537,6 @@
           // FEP-633c §2.2: register the ward hint on the stored object (no action yet).
           if (Guardianship.objectHasGuardians(o)) { try { db.prepare('UPDATE ap_timeline SET has_guardians = 1 WHERE id = ? AND slug = ?').run(o.id, s.slug); } catch { /* ignore */ } }
+          // FEP-9098: keep the note's custom-emoji tags so the C2S inbox read can serve them.
+          { const ej = extractEmojiTags(o.tag); if (ej) { try { db.prepare('UPDATE ap_timeline SET emoji_json = ? WHERE id = ? AND slug = ?').run(ej, o.id, s.slug); } catch { /* ignore */ } } }
           if (poll) { try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), o.id, s.slug); } catch { /* ignore */ } }
         }
@@ -2565,4 +2567,19 @@
 }
 
+// FEP-9098 custom emojis. Inbound: keep the note's Emoji tags (as JSON) so we
+// can serve them back. `extractEmojiTags` returns the JSON to store (or null);
+// `timelineEmojis` turns the stored JSON back into an AS2 `tag` array for the
+// C2S inbox read, so a client (Shaer) can render :shortcode: as an image.
+export function extractEmojiTags(tag) {
+  const arr = Array.isArray(tag) ? tag : (tag ? [tag] : []);
+  const emojis = arr.filter((t) => t && (Array.isArray(t.type) ? t.type[0] : t.type) === 'Emoji'
+    && typeof t.name === 'string' && t.icon);
+  return emojis.length ? JSON.stringify(emojis) : null;
+}
+export function timelineEmojis(emojiJson) {
+  try { const arr = emojiJson ? JSON.parse(emojiJson) : null; return (Array.isArray(arr) && arr.length) ? arr : undefined; }
+  catch { return undefined; }
+}
+
 // ── Cirkel = posts from the accounts you auto-boost ("feature an artist") ──
 let _abCount, _cirkelPosts, _cirkelMembers;
@@ -2720,4 +2737,6 @@
         const r = tlStmts().ins.run(o.id, slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.url || null, o.published || null, mediaFromNote(o), o.sensitive ? 1 : 0, o.summary || null);
         if (r && r.changes > 0) added++;
+        // FEP-9098: keep custom-emoji tags from backfilled posts too.
+        { const ej = extractEmojiTags(o.tag); if (ej) { try { db.prepare('UPDATE ap_timeline SET emoji_json = ? WHERE id = ? AND slug = ?').run(ej, o.id, slug); } catch { /* ignore */ } } }
         // Set poll_json if this is a poll and we don't already have it (COALESCE preserves a vote).
         if (poll) { try { db.prepare('UPDATE ap_timeline SET poll_json = COALESCE(poll_json, ?) WHERE id = ? AND slug = ?').run(JSON.stringify(poll), o.id, slug); } catch { /* ignore */ } }
@@ -3357,5 +3376,5 @@
   getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
   listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
-  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineAttachments, sendInteraction, voteOnPoll, voteOnRemotePoll,
+  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineAttachments, timelineEmojis, sendInteraction, voteOnPoll, voteOnRemotePoll,
   acceptGatedFollow, rejectGatedFollow, isWardGuardian, sendFollowDecision,
   parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
Index: test/custom-emojis.test.js
===================================================================
--- test/custom-emojis.test.js	(revision 97bacf2bd1fa5c118355c94e36492a020d61d024)
+++ test/custom-emojis.test.js	(revision 97bacf2bd1fa5c118355c94e36492a020d61d024)
@@ -0,0 +1,29 @@
+// FEP-9098: Klonkt keeps inbound custom-emoji tags and serves them on the C2S inbox 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');
+dbMod.initializeDatabase();
+const { extractEmojiTags, timelineEmojis } = await import('../src/services/ActivityPubService.js');
+const AP = { extractEmojiTags, timelineEmojis };
+
+test('extractEmojiTags keeps only Emoji tags; timelineEmojis round-trips', () => {
+  const tag = [
+    { type: 'Mention', href: 'https://s/u/x' },
+    { type: 'Emoji', name: ':wave:', icon: { type: 'Image', url: 'https://s/wave.png' } },
+    { type: 'Hashtag', name: '#hi' },
+  ];
+  const json = AP.extractEmojiTags(tag);
+  assert.ok(json);
+  const back = AP.timelineEmojis(json);
+  assert.equal(back.length, 1);
+  assert.equal(back[0].name, ':wave:');
+  assert.equal(back[0].icon.url, 'https://s/wave.png');
+});
+
+test('no emojis → null / undefined (nothing served)', () => {
+  assert.equal(AP.extractEmojiTags([{ type: 'Mention' }]), null);
+  assert.equal(AP.timelineEmojis(null), undefined);
+  assert.equal(AP.timelineEmojis('not json'), undefined);
+});
