diff --git a/.changeset/embedded-copy-prompt-clipboard.md b/.changeset/embedded-copy-prompt-clipboard.md
new file mode 100644
index 00000000..0937de6c
--- /dev/null
+++ b/.changeset/embedded-copy-prompt-clipboard.md
@@ -0,0 +1,5 @@
+---
+"@inkeep/open-knowledge": patch
+---
+
+Fix the empty-state "copy prompt" rows not working when OK runs inside an embedded host iframe (e.g. Claude). The copy buttons called the async Clipboard API directly, which the host frame's Permissions-Policy refuses inside an iframe — the rejection was swallowed silently, so nothing copied and the row never showed "Copied". The copy now routes through the shared clipboard adapter, whose `execCommand` fallback succeeds under the click's user activation where the policy-gated async write is blocked.
diff --git a/packages/app/src/components/empty-state/CopyablePromptList.dom.test.tsx b/packages/app/src/components/empty-state/CopyablePromptList.dom.test.tsx
new file mode 100644
index 00000000..2506ce82
--- /dev/null
+++ b/packages/app/src/components/empty-state/CopyablePromptList.dom.test.tsx
@@ -0,0 +1,100 @@
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { renderLinguiTemplate } from '@/test-utils/lingui-mock';
+
+mock.module('@lingui/react/macro', () => ({
+ Trans: ({ children }: { children?: ReactNode }) => <>{children}>,
+ useLingui: () => ({ t: renderLinguiTemplate }),
+}));
+
+const { CopyablePromptList } = await import('./CopyablePromptList');
+
+describe('CopyablePromptList', () => {
+ beforeEach(() => {
+ Reflect.deleteProperty(globalThis, 'okDesktop');
+ Object.defineProperty(globalThis.navigator, 'clipboard', {
+ configurable: true,
+ value: undefined,
+ });
+ });
+ afterEach(() => {
+ cleanup();
+ Reflect.deleteProperty(globalThis.document, 'execCommand');
+ });
+
+ test('flips a row to "Copied" when the clipboard write resolves', async () => {
+ const writeText = mock(() => Promise.resolve());
+ Object.defineProperty(globalThis.navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText },
+ });
+
+ render();
+
+ const button = screen.getByTestId('copy-prompt-button-competitor-research');
+ fireEvent.click(button);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('copy-prompt-button-competitor-research').textContent).toContain(
+ 'Copied',
+ );
+ });
+ expect(writeText).toHaveBeenCalledTimes(1);
+ });
+
+ test('falls back to execCommand and still copies when embedded-iframe policy refuses the async write', async () => {
+ const writeText = mock(() =>
+ Promise.reject(
+ Object.assign(new Error('blocked because of a permissions policy'), {
+ name: 'NotAllowedError',
+ }),
+ ),
+ );
+ Object.defineProperty(globalThis.navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText },
+ });
+ const execCommand = mock(() => true);
+ Object.defineProperty(globalThis.document, 'execCommand', {
+ configurable: true,
+ value: execCommand,
+ });
+
+ render();
+
+ fireEvent.click(screen.getByTestId('copy-prompt-button-competitor-research'));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('copy-prompt-button-competitor-research').textContent).toContain(
+ 'Copied',
+ );
+ });
+ expect(writeText).toHaveBeenCalledTimes(1);
+ expect(execCommand).toHaveBeenCalledWith('copy');
+ });
+
+ test('does not flip to "Copied" when every clipboard path is refused', async () => {
+ const writeText = mock(() =>
+ Promise.reject(Object.assign(new Error('blocked'), { name: 'NotAllowedError' })),
+ );
+ Object.defineProperty(globalThis.navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText },
+ });
+ const execCommand = mock(() => false);
+ Object.defineProperty(globalThis.document, 'execCommand', {
+ configurable: true,
+ value: execCommand,
+ });
+
+ render();
+ fireEvent.click(screen.getByTestId('copy-prompt-button-competitor-research'));
+
+ await waitFor(() => expect(execCommand).toHaveBeenCalledWith('copy'));
+ expect(writeText).toHaveBeenCalledTimes(1);
+ expect(screen.getByTestId('copy-prompt-button-competitor-research').textContent).not.toContain(
+ 'Copied',
+ );
+ });
+});
diff --git a/packages/app/src/components/empty-state/CopyablePromptList.tsx b/packages/app/src/components/empty-state/CopyablePromptList.tsx
index 7c72dacd..49ec9d01 100644
--- a/packages/app/src/components/empty-state/CopyablePromptList.tsx
+++ b/packages/app/src/components/empty-state/CopyablePromptList.tsx
@@ -6,6 +6,7 @@ import {
useCreateSuggestions,
} from '@/components/empty-state/use-create-suggestions';
import { Button } from '@/components/ui/button';
+import { scheduleClipboardWrite } from '@/lib/share/clipboard-adapter';
import { cn } from '@/lib/utils';
interface CopyablePromptListProps {
@@ -22,9 +23,7 @@ export function CopyablePromptList({ scenario, className }: CopyablePromptListPr
useEffect(() => () => clearTimeout(resetTimerRef.current), []);
function handleCopy(id: string, prompt: string) {
- if (!navigator.clipboard) return;
- void navigator.clipboard
- .writeText(prompt)
+ void scheduleClipboardWrite(prompt)
.then(() => {
setCopiedId(id);
clearTimeout(resetTimerRef.current);