Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision e2ea5c49d99947fd02bdda292a25027f868b7225)
+++ src/routes/activitypub.js	(revision fb30b5d0f27e2c88d8c14739a2d20267e7a655ed)
@@ -112,4 +112,7 @@
       sensitive: !!t.nsfw,
       summary: t.cw || undefined,
+      // Friends' media travels along (media_json → AS2 attachment), so the
+      // client renders their images/audio like own outbox posts.
+      attachment: AP.timelineAttachments(t.media_json),
     },
   }));
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision e2ea5c49d99947fd02bdda292a25027f868b7225)
+++ src/services/ActivityPubService.js	(revision fb30b5d0f27e2c88d8c14739a2d20267e7a655ed)
@@ -2434,4 +2434,19 @@
 export function getTimeline(slug, limit, offset) { return tlStmts().list.all(slug, limit || 50, offset || 0); }
 
+// 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
+// friend's images/audio/video natively, exactly like own outbox posts. The
+// stored `type` is the mediaType and may be ''. Malformed JSON yields
+// undefined and never blocks the item.
+export function timelineAttachments(mediaJson) {
+  try {
+    const list = mediaJson ? JSON.parse(mediaJson) : [];
+    const rows = (Array.isArray(list) ? list : [])
+      .filter((m) => m && m.url)
+      .map((m) => ({ type: 'Document', mediaType: m.type || undefined, url: m.url }));
+    return rows.length ? rows : undefined;
+  } catch { return undefined; }
+}
+
 // ── Cirkel = posts from the accounts you auto-boost ("feature an artist") ──
 let _abCount, _cirkelPosts, _cirkelMembers;
@@ -3090,5 +3105,5 @@
   getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
   listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
-  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, sendInteraction, voteOnPoll, voteOnRemotePoll,
+  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineAttachments, sendInteraction, voteOnPoll, voteOnRemotePoll,
   parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
   autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
Index: test/c2s-inbox.test.js
===================================================================
--- test/c2s-inbox.test.js	(revision e2ea5c49d99947fd02bdda292a25027f868b7225)
+++ test/c2s-inbox.test.js	(revision fb30b5d0f27e2c88d8c14739a2d20267e7a655ed)
@@ -26,2 +26,30 @@
   assert.ok(t.published);
 });
+
+// Friends' media must reach the client: media_json ([{url, type}]) becomes the
+// AS2 attachment array on the inbox item, like own outbox posts (Shaer P2).
+test('media_json maps to AS2 attachments', () => {
+  const rows = AP.timelineAttachments(JSON.stringify([
+    { url: 'https://r.test/m/p.png', type: 'image/png' },
+    { url: 'https://r.test/m/a.mp3', type: 'audio/mpeg' },
+  ]));
+  assert.equal(rows.length, 2);
+  assert.deepEqual(rows[0], { type: 'Document', mediaType: 'image/png', url: 'https://r.test/m/p.png' });
+  assert.deepEqual(rows[1], { type: 'Document', mediaType: 'audio/mpeg', url: 'https://r.test/m/a.mp3' });
+});
+
+test('unknown mediaType stays undefined, bad rows drop', () => {
+  const rows = AP.timelineAttachments(JSON.stringify([
+    { url: 'https://r.test/m/x.bin', type: '' },
+    { type: 'image/png' },              // no url -> dropped
+  ]));
+  assert.equal(rows.length, 1);
+  assert.equal(rows[0].mediaType, undefined);
+});
+
+test('empty or malformed media_json yields undefined and never throws', () => {
+  assert.equal(AP.timelineAttachments(null), undefined);
+  assert.equal(AP.timelineAttachments('[]'), undefined);
+  assert.equal(AP.timelineAttachments('not json'), undefined);
+  assert.equal(AP.timelineAttachments('{"not":"a list"}'), undefined);
+});
