Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision cc3cf7dfa8380e09d7fbe9a8e4875036d28eaede)
+++ src/config/database.js	(revision 6fd0e20cf8f015658ff3a7c4a2acd4c1102e2877)
@@ -549,4 +549,5 @@
   ensureColumn('ap_timeline', 'emoji_json', 'TEXT');         // FEP-9098 custom emoji Emoji tags from the inbound note, served back as `tag`
   ensureColumn('ap_timeline', 'link_json', 'TEXT');          // FEP-e232 object-link (quote/ref) tags from the inbound note, served back as `tag`
+  ensureColumn('ap_timeline', 'quote_json', 'TEXT');         // FEP-044f resolved quoted-post snapshot (author + content), for the embedded quote card
   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 cc3cf7dfa8380e09d7fbe9a8e4875036d28eaede)
+++ src/routes/activitypub.js	(revision 6fd0e20cf8f015658ff3a7c4a2acd4c1102e2877)
@@ -170,4 +170,8 @@
         return tags.length ? tags : undefined;
       })(),
+      // FEP-044f: the resolved quoted post (author + content), so the client
+      // renders an embedded quote card instead of a bare link. Omitted when the
+      // note has no quote or the quoted post could not be resolved.
+      'shaer:quote': AP.timelineQuote(t.quote_json),
     },
   }));
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision cc3cf7dfa8380e09d7fbe9a8e4875036d28eaede)
+++ src/services/ActivityPubService.js	(revision 6fd0e20cf8f015658ff3a7c4a2acd4c1102e2877)
@@ -1542,4 +1542,13 @@
           { const lj = extractLinkJson(o); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, 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 */ } }
+        }
+        // FEP-044f embedded quote card: resolve the quoted post out of band so
+        // the inbox response is not blocked on a remote fetch. Best-effort.
+        if (quoteHrefOf(o)) {
+          const slugs = subs.map((s) => s.slug);
+          resolveQuote(o).then((qj) => {
+            if (!qj) return;
+            for (const sl of slugs) { try { db.prepare('UPDATE ap_timeline SET quote_json = ? WHERE id = ? AND slug = ?').run(qj, o.id, sl); } catch { /* ignore */ } }
+          }).catch(() => { /* best-effort */ });
         }
         console.log('[AP] timeline +', actorUri, 'x' + subs.length);
@@ -2635,4 +2644,25 @@
 }
 
+// The URL of the quoted post, from either an object-level quote (FEP-044f) or a
+// quote-rel FEP-e232 Link tag. Used to resolve the embedded quote card.
+export function quoteHrefOf(note) {
+  const direct = extractQuoteUrl(note);
+  if (direct) return direct;
+  const arr = Array.isArray(note && note.tag) ? note.tag : (note && note.tag ? [note.tag] : []);
+  for (const t of arr) {
+    if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Link' || typeof t.href !== 'string') continue;
+    const rel = Array.isArray(t.rel) ? t.rel : (t.rel ? [t.rel] : []);
+    if (rel.some((r) => /quote/i.test(String(r)))) return t.href;
+  }
+  return null;
+}
+
+// Turn the stored quote snapshot back into the object the C2S inbox read serves
+// as `shaer:quote`, so the client can render the embedded quote card.
+export function timelineQuote(quoteJson) {
+  try { const q = quoteJson ? JSON.parse(quoteJson) : null; return (q && typeof q === 'object') ? q : undefined; }
+  catch { return undefined; }
+}
+
 // ── Cirkel = posts from the accounts you auto-boost ("feature an artist") ──
 let _abCount, _cirkelPosts, _cirkelMembers;
@@ -2731,5 +2761,5 @@
 // during a flux window, e.g. a fleet-wide update), and drops notes that are gone
 // (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
-const SELFHEAL_VERSION = 10; // v10: also capture FEP-044f object-level quotes (quote/quoteUrl/quoteUri/_misskey_quote) into link_json
+const SELFHEAL_VERSION = 11; // v11: also resolve the FEP-044f embedded quote card (quote_json) onto already-cached posts
 async function fetchNoteAP(url) {
   try {
@@ -2748,4 +2778,28 @@
   }
   return JSON.stringify(atts);
+}
+
+// FEP-044f embedded quote card: resolve the quoted post to a compact, sanitised
+// snapshot { url, author{name,handle,icon}, content, published, media } so the
+// client can render it as a nested card instead of a bare link. Best-effort and
+// SSRF-safe (apGetJson): returns null on any failure, and the client falls back
+// to the object-link chip. The content goes through the same sanitiser as every
+// other note, so the kid-safe guarantees hold.
+async function resolveQuote(note) {
+  const url = quoteHrefOf(note);
+  if (!url) return null;
+  const q = await apGetJson(url);
+  if (!q || typeof q !== 'object') return null;
+  const authorUri = typeof q.attributedTo === 'string' ? q.attributedTo
+    : (q.attributedTo && typeof q.attributedTo.id === 'string' ? q.attributedTo.id : null);
+  const ai = authorUri ? actorInfo(await fetchActor(authorUri), authorUri) : null;
+  const snapshot = {
+    url: safeUrl(q.url || q.id || url) || url,
+    author: ai ? { name: ai.name, handle: ai.handle, icon: ai.icon } : null,
+    content: HtmlSanitizerService.sanitize(q.content || ''),
+    published: q.published || null,
+    media: mediaFromNote(q),
+  };
+  return JSON.stringify(snapshot);
 }
 // A generic SSRF-safe AP GET (collections / pages).
@@ -2794,4 +2848,6 @@
         // FEP-e232 + FEP-044f: keep object-link/quote tags from backfilled posts too.
         { const lj = extractLinkJson(o); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, o.id, slug); } catch { /* ignore */ } } }
+        // FEP-044f: resolve the embedded quote card for backfilled posts too.
+        if (quoteHrefOf(o)) { const qj = await resolveQuote(o); if (qj) { try { db.prepare('UPDATE ap_timeline SET quote_json = ? WHERE id = ? AND slug = ?').run(qj, 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 */ } }
@@ -2914,5 +2970,5 @@
     if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots
     let rows = [];
-    try { rows = db.prepare('SELECT id, content, media_json, nsfw, cw, url, emoji_json, link_json FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }
+    try { rows = db.prepare('SELECT id, content, media_json, nsfw, cw, url, emoji_json, link_json, quote_json FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }
     let healed = 0, failed = 0;
     for (const r of rows) {
@@ -2928,6 +2984,9 @@
         const emoji = extractEmojiTags(note.tag);   // FEP-9098: re-capture custom-emoji tags (v8)
         const link = extractLinkJson(note);   // FEP-e232 + FEP-044f: re-capture object-link/quote tags (v9)
-        if ((html && html !== r.content) || media !== (r.media_json || '[]') || nsfw !== (r.nsfw || 0) || (cw || '') !== (r.cw || '') || (url && url !== r.url) || (emoji || '') !== (r.emoji_json || '') || (link || '') !== (r.link_json || '')) {
-          db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ?, url = COALESCE(?, url), emoji_json = ?, link_json = ? WHERE id = ?').run(html || r.content, media, nsfw, cw, url, emoji, link, r.id);
+        // FEP-044f: resolve the embedded quote card (v11). COALESCE-style: keep a
+        // cached snapshot if the quoted post is momentarily unreachable now.
+        const quote = quoteHrefOf(note) ? (await resolveQuote(note)) || r.quote_json || null : null;
+        if ((html && html !== r.content) || media !== (r.media_json || '[]') || nsfw !== (r.nsfw || 0) || (cw || '') !== (r.cw || '') || (url && url !== r.url) || (emoji || '') !== (r.emoji_json || '') || (link || '') !== (r.link_json || '') || (quote || '') !== (r.quote_json || '')) {
+          db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ?, url = COALESCE(?, url), emoji_json = ?, link_json = ?, quote_json = ? WHERE id = ?').run(html || r.content, media, nsfw, cw, url, emoji, link, quote, r.id);
           healed++;
         }
@@ -3433,5 +3492,5 @@
   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, sendInteraction, voteOnPoll, voteOnRemotePoll,
+  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, sendInteraction, voteOnPoll, voteOnRemotePoll,
   acceptGatedFollow, rejectGatedFollow, isWardGuardian, sendFollowDecision,
   parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
Index: test/object-links.test.js
===================================================================
--- test/object-links.test.js	(revision cc3cf7dfa8380e09d7fbe9a8e4875036d28eaede)
+++ test/object-links.test.js	(revision 6fd0e20cf8f015658ff3a7c4a2acd4c1102e2877)
@@ -6,6 +6,6 @@
 const dbMod = await import('../src/config/database.js');
 dbMod.initializeDatabase();
-const { extractObjectLinkTags, timelineObjectLinks, extractQuoteUrl, extractLinkJson } = await import('../src/services/ActivityPubService.js');
-const AP = { extractObjectLinkTags, timelineObjectLinks, extractQuoteUrl, extractLinkJson };
+const { extractObjectLinkTags, timelineObjectLinks, extractQuoteUrl, extractLinkJson, quoteHrefOf, timelineQuote } = await import('../src/services/ActivityPubService.js');
+const AP = { extractObjectLinkTags, timelineObjectLinks, extractQuoteUrl, extractLinkJson, quoteHrefOf, timelineQuote };
 
 test('extractObjectLinkTags keeps AS2-profiled ld+json and activity+json Links; drops plain links and mentions', () => {
@@ -59,2 +59,22 @@
   assert.equal(arr[0].href, 'https://s/objects/9');
 });
+
+// FEP-044f embedded quote card: the quoted-post URL feeds the resolver.
+test('quoteHrefOf reads an object-level quote or a quote-rel FEP-e232 Link', () => {
+  assert.equal(AP.quoteHrefOf({ quoteUrl: 'https://s/q1' }), 'https://s/q1');
+  assert.equal(AP.quoteHrefOf({
+    tag: [{ type: 'Link', href: 'https://s/q2', rel: 'https://misskey-hub.net/ns#_misskey_quote' }],
+  }), 'https://s/q2');
+  assert.equal(AP.quoteHrefOf({ content: 'plain, no quote' }), null);
+  // a plain (non-quote) FEP-e232 reference is not a quote target
+  assert.equal(AP.quoteHrefOf({ tag: [{ type: 'Link', href: 'https://s/ref', rel: 'mention' }] }), null);
+});
+
+test('timelineQuote round-trips the stored snapshot; junk → undefined', () => {
+  const snap = JSON.stringify({ url: 'https://s/q', author: { name: 'A', handle: '@a@s', icon: null }, content: '<p>hi</p>' });
+  const back = AP.timelineQuote(snap);
+  assert.equal(back.url, 'https://s/q');
+  assert.equal(back.author.handle, '@a@s');
+  assert.equal(AP.timelineQuote(null), undefined);
+  assert.equal(AP.timelineQuote('not json'), undefined);
+});
