Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 6bc2e31be027c19ca280d49b79711d4e4a1952db)
+++ src/config/database.js	(revision fc404105536bc9fcadc1fdb7dceb0449fee77e98)
@@ -132,5 +132,10 @@
   ensureColumn('sites', 'show_search',     'INTEGER DEFAULT 1');
   ensureColumn('sites', 'show_archive_link', 'INTEGER DEFAULT 1');
-  ensureColumn('sites', 'og_theme', 'TEXT');               // OG share-card variant: NULL=auto (follow site theme) | 'light' | 'dark'
+  // Gated feature (FEP-633c): may external (non-fediverse) embeds be shown to
+  // this account? NULL = auto, which means OFF for a ward and ON for anyone
+  // else. The guardians flip it; the gate itself lives server-side, so a ward
+  // never even receives the thumbnail it is not allowed to see.
+  ensureColumn('sites', 'external_embeds', 'INTEGER');
+  ensureColumn('sites', 'og_theme', 'TEXT');             // OG share-card variant: NULL=auto (follow site theme) | 'light' | 'dark'
 
   // Per-post noindex + type
@@ -550,4 +555,5 @@
   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', 'embed_json', 'TEXT');         // resolved EXTERNAL embed (oEmbed/provider), thumbnail-only; gated per site (sites.external_embeds)
   ensureColumn('ap_timeline', 'author_emoji_json', 'TEXT');  // FEP-9098 custom emojis in the author's display name (shaer:author.emojis)
   ensureColumn('ap_timeline', 'reblog_emoji_json', 'TEXT');  // FEP-9098 custom emojis in the booster's display name (shaer:booster.emojis)
Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 6bc2e31be027c19ca280d49b79711d4e4a1952db)
+++ src/routes/activitypub.js	(revision fc404105536bc9fcadc1fdb7dceb0449fee77e98)
@@ -145,4 +145,10 @@
   if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
   const base = baseUrl(req);
+  // Gated feature (FEP-633c): may this account see EXTERNAL embeds? A ward's
+  // world outside the fediverse is the guardians' call. The gate is applied
+  // here, at serialisation: a blocked embed is never sent, because an embed the
+  // client merely hides has still been delivered to the device.
+  const isWard = (() => { try { return Guardianship.listGuardians(auth.site.slug).length > 0; } catch { return false; } })();
+  const embedsAllowed = Guardianship.externalEmbedsAllowed(auth.site.external_embeds, isWard);
   const items = AP.getTimeline(auth.site.slug, 60).map((t) => ({
     id: `${t.id}#create`,
@@ -195,4 +201,7 @@
       'shaer:liked': !!t.liked,
       'shaer:boosted': !!t.boosted,
+      // An external (non-fediverse) embed, thumbnail-only and never an iframe.
+      // Omitted entirely when the gate is closed (see above).
+      'shaer:embed': embedsAllowed ? AP.timelineEmbed(t.embed_json) : undefined,
     },
   }));
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 };
Index: test/external-embeds.test.js
===================================================================
--- test/external-embeds.test.js	(revision fc404105536bc9fcadc1fdb7dceb0449fee77e98)
+++ test/external-embeds.test.js	(revision fc404105536bc9fcadc1fdb7dceb0449fee77e98)
@@ -0,0 +1,44 @@
+// External embeds are a gated feature (FEP-633c): a ward's world outside the
+// fediverse is the guardians' call, and the gate is applied server-side.
+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 { externalEmbedsAllowed } = await import('../src/services/guardianship/notes.js');
+const { firstExternalUrl, timelineEmbed } = await import('../src/services/ActivityPubService.js');
+
+test('auto (no setting): off for a ward, on for anyone else', () => {
+  assert.equal(externalEmbedsAllowed(null, true), false, 'a ward gets no external embeds by default');
+  assert.equal(externalEmbedsAllowed(null, false), true, 'a free actor does');
+  assert.equal(externalEmbedsAllowed(undefined, true), false);
+});
+
+test('an explicit guardian decision wins over the default, both ways', () => {
+  assert.equal(externalEmbedsAllowed(1, true), true, 'guardians may open it for a ward');
+  assert.equal(externalEmbedsAllowed(0, false), false, 'and may close it for anyone');
+});
+
+test('firstExternalUrl picks the first real link, skipping mentions and hashtags', () => {
+  const html = '<p><a href="https://s/@bob" class="u-url mention">@bob</a> '
+    + '<a href="https://s/tags/x" class="mention hashtag">#x</a> '
+    + 'kijk: <a href="https://v.example/watch/1">dit</a> en <a href="https://later.example/">dat</a></p>';
+  assert.equal(firstExternalUrl(html), 'https://v.example/watch/1');
+});
+
+test('firstExternalUrl ignores non-http hrefs and empty content', () => {
+  assert.equal(firstExternalUrl('<a href="javascript:alert(1)">x</a>'), null);
+  assert.equal(firstExternalUrl('<p>geen links</p>'), null);
+  assert.equal(firstExternalUrl(null), null);
+});
+
+test('timelineEmbed round-trips a stored card and refuses junk', () => {
+  const stored = JSON.stringify({ url: 'https://v.example/1', kind: 'oembed', title: 'A talk', media: [{ url: 'https://v.example/t.jpg' }] });
+  const back = timelineEmbed(stored);
+  assert.equal(back.title, 'A talk');
+  assert.equal(back.media[0].url, 'https://v.example/t.jpg');
+  assert.equal(timelineEmbed(null), undefined);
+  assert.equal(timelineEmbed('not json'), undefined);
+  assert.equal(timelineEmbed('{"title":"no url"}'), undefined, 'a card without a url is not a card');
+});
