diff --git a/app/src/agentworld/pages/FeedSection.test.tsx b/app/src/agentworld/pages/FeedSection.test.tsx
index 2a36d45a6..ba8458fcc 100644
--- a/app/src/agentworld/pages/FeedSection.test.tsx
+++ b/app/src/agentworld/pages/FeedSection.test.tsx
@@ -16,6 +16,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { PaymentRequiredError } from '../../lib/agentworld/invokeApiClient';
import { fetchWalletStatus } from '../../services/walletApi';
+import { openUrl } from '../../utils/openUrl';
import { apiClient } from '../AgentWorldShell';
import FeedSection, { FEED_PAGE_SIZE } from './FeedSection';
@@ -63,6 +64,10 @@ vi.mock('../hooks/useTinyplaceStream', () => ({
vi.mock('../../services/walletApi', () => ({ fetchWalletStatus: vi.fn() }));
+// External-link opener — assert the composer's media CTA hands off to the OS
+// browser without actually invoking Tauri. Returns a Promise (the CTA voids it).
+vi.mock('../../utils/openUrl', () => ({ openUrl: vi.fn(() => Promise.resolve()) }));
+
// ── Sample data (generic placeholders) ───────────────────────────────────────
const MY_AGENT_ID = 'my-addr';
@@ -665,6 +670,29 @@ describe('post composer', () => {
});
expect(screen.queryByPlaceholderText(/what's on your mind/i)).not.toBeInTheDocument();
});
+
+ // Post media is web-only (#4924): the tiny.place backend serves no post-media
+ // field, so instead of an in-app upload the composer points users to the web
+ // app. Unit coverage only by design — the one real cross-process effect (the OS
+ // browser opening) rides on the mocked `openUrl` and isn't worth a WDIO E2E.
+ test('renders the web-only "add media" note in the composer', async () => {
+ vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
+ render();
+ const note = await screen.findByTestId('add-media-on-web');
+ expect(note).toHaveTextContent(/add photos or gifs on tiny\.place/i);
+ expect(screen.getByTestId('add-media-on-web-cta')).toHaveAttribute(
+ 'data-analytics-id',
+ 'feed.addMediaOnWeb'
+ );
+ });
+
+ test('media CTA opens the tiny.place feed via openUrl', async () => {
+ const user = userEvent.setup();
+ vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
+ render();
+ await user.click(await screen.findByTestId('add-media-on-web-cta'));
+ expect(vi.mocked(openUrl)).toHaveBeenCalledWith('https://tiny.place');
+ });
});
// ── Delete actions ────────────────────────────────────────────────────────────
diff --git a/app/src/agentworld/pages/FeedSection.tsx b/app/src/agentworld/pages/FeedSection.tsx
index a62815c80..dec52b19d 100644
--- a/app/src/agentworld/pages/FeedSection.tsx
+++ b/app/src/agentworld/pages/FeedSection.tsx
@@ -14,6 +14,13 @@
* - Inline post composer at the top of the feed (refetches feed on success)
* - Delete post / delete comment (own content only, via an in-app ConfirmDialog)
*
+ * Post *media* (images / GIFs) is intentionally NOT composable in-app: the
+ * tiny.place backend serves no media field on posts (neither `PostCreate` nor
+ * the read-side `Post` / `GqlPost` in the vendored SDK carry one), so an
+ * in-app upload would round-trip to nothing and render nowhere. Attaching media
+ * is therefore web-only on tiny.place; the composer links users there for it.
+ * See #4924 (and the same web-only resolution as marketplace selling, #4920).
+ *
* Pattern mirrors ExploreSection / MarketplaceSection: useState + useEffect
* fetch, PanelScaffold wrapper, StatusBlock for loading/error/empty states.
*/
@@ -32,6 +39,7 @@ import {
} from '../../lib/agentworld/invokeApiClient';
import { useT } from '../../lib/i18n/I18nContext';
import { fetchWalletStatus } from '../../services/walletApi';
+import { openUrl } from '../../utils/openUrl';
import { apiClient } from '../AgentWorldShell';
import ConfirmDialog from '../components/ConfirmDialog';
import StatusBlock from '../components/StatusBlock';
@@ -40,6 +48,18 @@ import { relativeTime } from './relativeTime';
const log = debug('agentworld:feed');
+// Attaching images / GIFs to a post is web-only — the tiny.place backend serves
+// no post-media field, so we point users at the web app for it (#4924). tiny.place's
+// home page is the feed, where the media composer lives.
+//
+// Single hardcoded origin by design: the *API* host varies by env (api.tiny.place
+// vs staging-api.tiny.place), but there is exactly one human-facing website —
+// `tiny.place` — used unconditionally across the codebase for the same reason
+// (post permalinks: `TINYPLACE_WEB_ORIGIN` in tinyplace/agent_tools/flows_write.rs;
+// `FUND_PAGE_URL` in X402ConfirmDialog.tsx; `SELL_ON_WEB_URL` in IdentitiesSection.tsx).
+// There is no per-network web frontend to derive this from.
+const ADD_MEDIA_ON_WEB_URL = 'https://tiny.place';
+
/**
* Home-feed items fetched per page (also the initial page size). The
* `tinyplace_graphql_home_feed` RPC accepts `limit`/`offset`
@@ -354,6 +374,28 @@ function FeedComposer({ myAgentId, onPostCreated }: FeedComposerProps) {
+ {/* Adding images / GIFs to a post is web-only (#4924): the tiny.place
+ backend serves no post-media field, so an in-app upload would render
+ nowhere. Point users at the web app instead of dead-ending. */}
+
+ Add photos or GIFs on tiny.place.
+
+
);
}
diff --git a/docs/superpowers/plans/2026-07-30-tinyplace-feed-post-media-web-only.md b/docs/superpowers/plans/2026-07-30-tinyplace-feed-post-media-web-only.md
new file mode 100644
index 000000000..3e8d4a29a
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-30-tinyplace-feed-post-media-web-only.md
@@ -0,0 +1,46 @@
+# Feed Post Media Web-Only Implementation Plan (#4924)
+
+**Goal:** Replace the text-only feed composer's missing-media gap with a note
+pointing users to tiny.place, and record that post media is web-only by design.
+Mirrors the identity-marketplace seller-web-only resolution (#4920 / PR #5193).
+
+**Architecture:** Additive UI only. A persistent note at the bottom of
+`FeedComposer` opens `https://tiny.place` via the existing `openUrl()` helper. No
+SDK, core, `manifest.rs`, or submodule changes — the tiny.place backend serves no
+post-media field. Scope recorded in two doc comments.
+
+**Tech stack:** React 19 + TypeScript, Vitest + Testing Library,
+`app/src/utils/openUrl.ts`, Rust module doc comment.
+
+## Global constraints
+
+- **No i18n:** `FeedComposer` is hardcoded English (`"What's on your mind?"`,
+ `"Post"`, `"Posting…"`). New strings match — no `useT()`, no locale-file edits.
+- **Web URL is a hardcoded prod constant:** `https://tiny.place` (home = feed).
+ Mirrors `SELL_ON_WEB_URL` / `FUND_PAGE_URL`.
+- **External links go through `openUrl`** — never a raw `window.open`.
+- **Out of scope (do not touch):** `vendor/tinyplace/**` (incl. the submodule
+ pointer), `manifest.rs`, `invokeApiClient.ts`, the compose/like/comment flows.
+
+## Tasks
+
+- [x] **Task 1 — Composer note + CTA.** `FeedSection.tsx`: import `openUrl`; add
+ `ADD_MEDIA_ON_WEB_URL`; render a note (`data-testid="add-media-on-web"`) + text
+ CTA (`data-testid="add-media-on-web-cta"`, `data-analytics-id="feed.addMediaOnWeb"`)
+ at the bottom of `FeedComposer` that `void openUrl(ADD_MEDIA_ON_WEB_URL)`. Header
+ docstring gains a "post media is web-only" note.
+- [x] **Task 2 — Rust scope note.** `src/openhuman/tinyplace/mod.rs`: add a
+ "Feed scope (post media is web-only)" doc-comment section after the marketplace
+ one. Docs-only; `cargo fmt --check` verifies.
+- [x] **Task 3 — Tests.** `FeedSection.test.tsx`: mock `openUrl`; assert the note
+ renders and the CTA calls `openUrl('https://tiny.place')`.
+- [x] **Task 4 — GitHub bookkeeping (outward-facing).** Done: commented the
+ web-only resolution on #4924, and marked the #4776 §2 "Images / media in posts"
+ item N/A (web-only) via an epic status comment (the epic body is owner-gated).
+
+## Verification
+
+- `pnpm --filter openhuman-app test` — FeedSection green (incl. 2 new cases).
+- `tsc --noEmit` clean; `eslint` no new errors; `prettier --check` clean.
+- `cargo fmt --check` clean (doc-comment-only Rust change).
+- `git diff vendor/tinyplace` empty — submodule pointer untouched (mergeable).
diff --git a/docs/superpowers/specs/2026-07-30-tinyplace-feed-post-media-web-only-design.md b/docs/superpowers/specs/2026-07-30-tinyplace-feed-post-media-web-only-design.md
new file mode 100644
index 000000000..fc120d78c
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-30-tinyplace-feed-post-media-web-only-design.md
@@ -0,0 +1,63 @@
+# Design: Feed post media is web-only (#4924)
+
+Part of #4776 (Tiny Place audit) · §2 Feed · sibling of the #4920 web-only resolution (PR #5193).
+
+## Problem
+
+The audit item "Images / media in posts" fails at the **contract** level, not just
+the UI. Posts are text-only end-to-end:
+
+- The vendored `tinyplace` SDK carries no media field on the **write** side
+ (`PostCreate` = `{ body, content_type, post_id }`) or the **read** side
+ (`Post` / `GqlPost` = `body`, counts, author, timestamps — no media).
+- The tiny.place **backend** serves no post-media field. Verified against
+ tiny.place `main` (`d2545054`, the commit openhuman already pins): the SDK
+ structs there still have no `image` / `media` / `gif` field.
+- The desktop composer sends `{ body }` only (`FeedSection.tsx` →
+ `feeds.createPost` → `manifest.rs` → SDK `feeds::create_post`).
+
+Because `vendor/tinyplace` is a **pinned git submodule** (openhuman can only bump
+the pointer to a commit that exists on the tiny.place remote), the media contract
+cannot be added from this repo. A prior WIP branch pinned the submodule to a
+**local-only** SDK commit to make it build — that is unmergeable (a fresh clone/CI
+cannot fetch the commit) and, being SDK-only, still would not render media because
+the backend serves none.
+
+## Decision
+
+Resolve **web-only**, mirroring the identity-marketplace seller resolution (#4920 /
+PR #5193). Rather than ship an in-app upload against a non-existent contract — which
+would round-trip to nothing and render nowhere — the desktop feed composer shows a
+small note that attaching images/GIFs happens on tiny.place, with a CTA that opens
+the web app via the existing `openUrl()` helper.
+
+## Changes
+
+1. **UI** (`app/src/agentworld/pages/FeedSection.tsx`) — additive only. A subtle
+ note + text CTA at the bottom of `FeedComposer` opens `https://tiny.place` (the
+ home page is the feed) through `openUrl()`. Hardcoded prod URL, matching the
+ `SELL_ON_WEB_URL` (IdentitiesSection) and `FUND_PAGE_URL` (X402ConfirmDialog)
+ precedents. Strings are hardcoded English to match the surrounding
+ `FeedComposer`, which uses no `useT()` (same rationale as #5193).
+
+2. **Scope docstrings** — a "Feed scope (post media is web-only)" note in the
+ `FeedSection.tsx` header and in `src/openhuman/tinyplace/mod.rs`, next to the
+ existing marketplace web-only note.
+
+3. **Test** (`FeedSection.test.tsx`) — two co-located Vitest cases: the note
+ renders in the composer, and the CTA calls `openUrl('https://tiny.place')`
+ (mock `openUrl`). Covers the new diff lines for the ≥80% changed-line gate.
+
+## Out of scope (YAGNI)
+
+- Vendored SDK media fields (`PostCreate.image` / `PostMedia` / `Post.media`).
+- Core `manifest.rs` post-media handling; `invokeApiClient.ts` media params.
+- Any `vendor/tinyplace` submodule pointer bump.
+- Full i18n conversion of the composer (new strings are hardcoded English to match).
+
+## Follow-up to genuinely close in-app (tiny.place-first, not this repo)
+
+1. tiny.place backend: persist + serve a post-media field.
+2. tiny.place SDK: add media to `PostCreate` + read-side `Post` / `GqlPost`; release.
+3. openhuman: bump the submodule pointer, then wire handler + composer upload +
+ renderer. Tracked under the Tiny Place epic (#4190 / #4776).
diff --git a/src/openhuman/tinyplace/mod.rs b/src/openhuman/tinyplace/mod.rs
index f42c1b3b6..434ae1464 100644
--- a/src/openhuman/tinyplace/mod.rs
+++ b/src/openhuman/tinyplace/mod.rs
@@ -34,6 +34,15 @@
//! `marketplace_list_offers` takes a `name` filter for offers targeting a handle
//! you own. The desktop Trading tab links sellers to the web app for the actions
//! it cannot perform. See #4920.
+//!
+//! ## Feed scope (post media is web-only)
+//!
+//! Feed posts here are **text-only**. Attaching images / GIFs to a post is
+//! **web-only on tiny.place**: the backend serves no media field — neither the
+//! write-side `PostCreate` nor the read-side `Post` / `GqlPost` in the vendored
+//! SDK carries one — so an in-app upload would round-trip to nothing and render
+//! nowhere. The desktop feed composer links users to the web app to attach
+//! media instead of dead-ending. See #4924.
pub(crate) mod agent;
mod agent_tools;