Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 6bc2e31be027c19ca280d49b79711d4e4a1952db)
+++ src/services/ActivityPubService.js	(revision fc404105536bc9fcadc1fdb7dceb0449fee77e98)
@@ -22,4 +22,5 @@
 import HtmlSanitizerService from './HtmlSanitizerService.js';
 import AudioEmbedService from './AudioEmbedService.js';
+import EmbedResolver from './EmbedResolver.js';
 import Push from './PushService.js';
 import { getTenancy } from './SettingsService.js';
@@ -1575,4 +1576,14 @@
             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 */ });
+        } else {
+          // No fediverse quote: try an EXTERNAL embed (oEmbed / known provider),
+          // thumbnail-only. Also out of band, and stored for everyone; the gate
+          // that decides who may SEE it is applied at serve time (§5.3-style
+          // gated feature, see the inbox read).
+          const slugs = subs.map((s) => s.slug);
+          resolveExternalEmbed(o.content).then((ej) => {
+            if (!ej) return;
+            for (const sl of slugs) { try { db.prepare('UPDATE ap_timeline SET embed_json = ? WHERE id = ? AND slug = ?').run(ej, o.id, sl); } catch { /* ignore */ } }
           }).catch(() => { /* best-effort */ });
         }
@@ -2837,4 +2848,51 @@
 }
 
+// The first external (non-fediverse) link in a note, resolved to the same card
+// shape as a quote: THUMBNAIL ONLY, never the provider's iframe. An arbitrary
+// third-party frame inside a kid-safe app is a hole you cannot close again, so
+// the embed carries an image and a title and nothing executable.
+// Returns the JSON to store, or null when there is nothing worth showing.
+export async function resolveExternalEmbed(html) {
+  const first = firstExternalUrl(html);
+  if (!first) return null;
+  const io = EmbedResolver.liveIO({
+    safeFetch,
+    detectProvider: (u) => AudioEmbedService.detectProvider(u),
+    fetchActor,
+    actorInfo,
+  });
+  const card = await EmbedResolver.resolveEmbed(first, io).catch(() => null);
+  // 'ap' is handled by the quote path; a bare 'link' is not worth a card.
+  if (!card || card.kind === 'ap' || card.kind === 'link') return null;
+  const thumb = (card.media || []).find((m) => m && m.url);
+  if (!thumb && !card.title) return null;
+  return JSON.stringify({
+    url: card.url,
+    kind: card.kind,                       // 'provider' | 'oembed'
+    provider: card.provider || null,
+    title: card.title || null,
+    author: card.author || null,
+    media: thumb ? [thumb] : [],           // thumbnail only, no html/iframe
+  });
+}
+
+/** The first http(s) link in sanitized note HTML that is not a mention/hashtag. */
+export function firstExternalUrl(html) {
+  if (!html || typeof html !== 'string') return null;
+  for (const m of html.matchAll(/<a\b[^>]*href=["']([^"']+)["'][^>]*>/gi)) {
+    const tag = m[0];
+    if (/\b(mention|hashtag|u-url)\b/i.test(tag) && /mention|hashtag/i.test(tag)) continue;
+    const href = m[1];
+    if (/^https?:\/\//i.test(href)) return href;
+  }
+  return null;
+}
+
+/** The stored external-embed card, for the C2S read. */
+export function timelineEmbed(embedJson) {
+  try { const e = embedJson ? JSON.parse(embedJson) : null; return (e && typeof e === 'object' && e.url) ? e : undefined; }
+  catch { return undefined; }
+}
+
 // FEP-044f embedded quote card: resolve the quoted post to a compact, sanitised
 // snapshot { url, author{name,handle,icon}, content, published, media } so the
@@ -3576,5 +3634,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, timelineQuote, sendInteraction, voteOnPoll, voteOnRemotePoll,
+  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, sendInteraction, voteOnPoll, voteOnRemotePoll,
   acceptGatedFollow, rejectGatedFollow, isWardGuardian, sendFollowDecision,
   parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
Index: src/services/guardianship/index.js
===================================================================
--- src/services/guardianship/index.js	(revision 6bc2e31be027c19ca280d49b79711d4e4a1952db)
+++ src/services/guardianship/index.js	(revision fc404105536bc9fcadc1fdb7dceb0449fee77e98)
@@ -16,5 +16,5 @@
  */
 export { SHAER_CONTEXT, GUARDIAN_RELATIONSHIP, GUARDIAN_RELATIONSHIP_COMPACT, isGuardianRelationship } from './context.js';
-export { helpRequestProps, isHelpRequest, waveProps, isWave, hasGuardiansProps, objectHasGuardians } from './notes.js';
+export { helpRequestProps, isHelpRequest, waveProps, isWave, hasGuardiansProps, objectHasGuardians, externalEmbedsAllowed } from './notes.js';
 export { wireDelivery, c2sVisibility, deliverDirectNote } from './delivery.js';
 export { wireHandshake, handleOutbox as handleGuardianshipOutbox, handleInbox as handleGuardianshipInbox, parseRelationship } from './handshake.js';
Index: src/services/guardianship/notes.js
===================================================================
--- src/services/guardianship/notes.js	(revision 6bc2e31be027c19ca280d49b79711d4e4a1952db)
+++ src/services/guardianship/notes.js	(revision fc404105536bc9fcadc1fdb7dceb0449fee77e98)
@@ -15,4 +15,21 @@
   try { return (slug && listGuardians(slug).length) ? { 'shaer:hasGuardians': true } : {}; }
   catch { return {}; }
+}
+
+/**
+ * May EXTERNAL (non-fediverse) embeds be shown to this account?
+ *
+ * A gated feature in the FEP-633c sense: a ward's world outside the fediverse
+ * is the guardians' call. `setting` is `sites.external_embeds`:
+ *   null/undefined → auto: off for a ward, on for anyone else
+ *   0 → off, 1 → on (the guardians decided)
+ *
+ * Pure, so the rule is testable on its own. The gate is applied SERVER-side:
+ * a blocked embed is never serialised into the feed, because an embed that the
+ * client merely hides has still been delivered.
+ */
+export function externalEmbedsAllowed(setting, isWard) {
+  if (setting === 0 || setting === 1) return setting === 1;
+  return !isWard;
 }
 
@@ -48,3 +65,3 @@
 }
 
-export default { helpRequestProps, isHelpRequest, waveProps, isWave, hasGuardiansProps, objectHasGuardians };
+export default { helpRequestProps, isHelpRequest, waveProps, isWave, hasGuardiansProps, objectHasGuardians, externalEmbedsAllowed };
