From 38fa213d17aeb4cff19a59973bc461122b486a81 Mon Sep 17 00:00:00 2001
From: rijulshrestha
Date: Mon, 14 Sep 2026 13:16:11 +0100
Subject: [PATCH 1/2] feat: add an embeddable /embed route with per-pane shell
options
---
docs/embedding.md | 40 +++
src/hooks.server.ts | 16 +-
src/lib/components/Portal.svelte | 6 +-
src/lib/components/ide/IdeShell.svelte | 349 ++++++++++++++-----------
src/lib/embed/options.ts | 57 ++++
src/lib/ide/shell-options.ts | 24 ++
src/lib/utils/platform.ts | 13 +
src/routes/+layout.svelte | 14 +-
src/routes/embed/+page.svelte | 40 +++
static/_headers | 6 +-
10 files changed, 398 insertions(+), 167 deletions(-)
create mode 100644 docs/embedding.md
create mode 100644 src/lib/embed/options.ts
create mode 100644 src/lib/ide/shell-options.ts
create mode 100644 src/routes/embed/+page.svelte
diff --git a/docs/embedding.md b/docs/embedding.md
new file mode 100644
index 0000000..797907a
--- /dev/null
+++ b/docs/embedding.md
@@ -0,0 +1,40 @@
+# Embedding BrowserCode
+
+BrowserCode runs entirely in the browser, so an embed is our page inside your iframe.
+
+```html
+
+```
+
+## Your page must be cross-origin isolated
+
+This is the one requirement we cannot satisfy for you. BrowserPod needs `SharedArrayBuffer`, which browsers only expose on cross-origin isolated pages, and isolation is inherited from the top-level document. The page doing the embedding has to send both headers itself:
+
+```
+Cross-Origin-Opener-Policy: same-origin
+Cross-Origin-Embedder-Policy: require-corp
+```
+
+and the iframe needs `allow="cross-origin-isolated"`. Without all three the embed loads and then reports that the headers are missing.
+
+Be aware that `require-corp` blocks cross-origin resources that do not opt in, which can break images, fonts, analytics and third-party iframes elsewhere on your page. Deploy `Cross-Origin-Embedder-Policy-Report-Only` first to see what would break, and consider `credentialless` instead, which clears most of it. If you cannot enable these headers at all, open BrowserCode in a new tab instead, which needs nothing from your page.
+
+## Options
+
+| Parameter | Value |
+| ----------- | ----------------------------------------------------------------------------- |
+| `repo` | Any GitHub URL or `owner/repo`, optionally `.../tree//` |
+| `framework` | A template id (`vite`, `react`, `svelte`, `vue`, `nextjs`, `nuxt`, `express`) |
+| `view` | Comma separated: `files`, `search`, `editor`, `terminal`, `preview` |
+
+`view` defaults to every pane. Pass `repo` or `framework`, not both; with neither, the default template boots. Controls that stop making sense are dropped automatically, so a preview-only embed has no hide button and no port badge.
+
+```
+/embed?repo=https://github.com/user/repo/tree/main/examples/demo
+/embed?framework=vite&view=preview
+/embed?framework=nextjs&view=files,editor,terminal
+```
diff --git a/src/hooks.server.ts b/src/hooks.server.ts
index 8006769..3279dbb 100644
--- a/src/hooks.server.ts
+++ b/src/hooks.server.ts
@@ -1,13 +1,21 @@
import type { Handle } from '@sveltejs/kit';
+const AGENTS_CSP =
+ "frame-ancestors 'self' https://browserpod.io https://*.browserpod.io https://*.browserpod.pages.dev";
+
+/** Dev mirror of `static/_headers`; the static build has no server, so keep the two in step. */
export const handle: Handle = async ({ event, resolve }) => {
const response = await resolve(event);
response.headers.set('Cross-Origin-Opener-Policy', 'same-origin');
response.headers.set('Cross-Origin-Embedder-Policy', 'require-corp');
response.headers.set('Cross-Origin-Resource-Policy', 'cross-origin');
- response.headers.set(
- 'Content-Security-Policy',
- "frame-ancestors 'self' https://browserpod.io https://*.browserpod.io https://*.browserpod.pages.dev"
- );
+
+ // Clears the blanket frame-ancestors vite.config.ts sets, so only /agents stays unframable.
+ if (event.url.pathname.startsWith('/agents')) {
+ response.headers.set('Content-Security-Policy', AGENTS_CSP);
+ } else {
+ response.headers.delete('Content-Security-Policy');
+ }
+
return response;
};
diff --git a/src/lib/components/Portal.svelte b/src/lib/components/Portal.svelte
index 334f14f..f8b14f5 100644
--- a/src/lib/components/Portal.svelte
+++ b/src/lib/components/Portal.svelte
@@ -10,9 +10,11 @@
onBeforeReload?: () => Promise;
/** Collapses the pane; omitted by hosts with nowhere to collapse to. */
onCollapse?: () => void;
+ /** Which server the frame shows. A lone preview has nothing to choose between. */
+ showPort?: boolean;
};
- let { portal, onBeforeReload, onCollapse }: Props = $props();
+ let { portal, onBeforeReload, onCollapse, showPort = true }: Props = $props();
/** Matches the sweep animation below. */
const SWEEP_MS = 620;
@@ -112,7 +114,7 @@
{/if}
- {#if portal.url}
+ {#if portal.url && showPort}
diff --git a/src/lib/embed/options.ts b/src/lib/embed/options.ts
new file mode 100644
index 0000000..7b653d1
--- /dev/null
+++ b/src/lib/embed/options.ts
@@ -0,0 +1,57 @@
+/** Parses the `/embed` query string, so a host page composes an embed entirely from the URL. */
+import { defaultFrameworkId, isFrameworkId, type FrameworkId } from '$lib/config/frameworks';
+import { parseGitHubUrl, type ParsedRepo } from '$lib/github/parse';
+import type { ShellOptions } from '$lib/ide/shell-options';
+
+export type EmbedSource =
+ | { kind: 'framework'; id: FrameworkId }
+ | { kind: 'repo'; ref: ParsedRepo };
+
+export type EmbedOptions = { source: EmbedSource; shell: ShellOptions };
+
+const VIEWS = ['files', 'search', 'editor', 'terminal', 'preview'] as const;
+type View = (typeof VIEWS)[number];
+
+/** Narrows a raw `view=` entry, so unknown names fall out of the list rather than throwing. */
+function isView(value: string): value is View {
+ return (VIEWS as readonly string[]).includes(value);
+}
+
+/** Null when the URL names a project we cannot boot, so the route can report the bad parameter. */
+export function parseEmbedOptions(url: URL): EmbedOptions | null {
+ const source = parseSource(url);
+ return source && { source, shell: parseShell(url) };
+}
+
+/** `repo` wins over `framework`; a bad value of either is an error, not a silent fallback. */
+function parseSource(url: URL): EmbedSource | null {
+ const repo = url.searchParams.get('repo');
+ if (repo) {
+ const ref = parseGitHubUrl(repo);
+ return ref && { kind: 'repo', ref };
+ }
+ const framework = url.searchParams.get('framework');
+ if (framework === null) return { kind: 'framework', id: defaultFrameworkId };
+ return isFrameworkId(framework) ? { kind: 'framework', id: framework } : null;
+}
+
+/** Turns `view=` into the shell's panes, leaving the playground-only chrome off throughout. */
+function parseShell(url: URL): ShellOptions {
+ const requested = url.searchParams.get('view');
+ const views = requested ? requested.split(',').map((view) => view.trim()) : [...VIEWS];
+ const shown = new Set(views.filter(isView));
+ // An unrecognised `view` would otherwise render an empty shell.
+ if (shown.size === 0) VIEWS.forEach((view) => shown.add(view));
+
+ return {
+ // Names the project and open file, which is chrome without an editor.
+ header: shown.has('editor'),
+ fileTree: shown.has('files'),
+ search: shown.has('search'),
+ editor: shown.has('editor'),
+ terminal: shown.has('terminal'),
+ preview: shown.has('preview'),
+ tools: false,
+ leaveGuard: false
+ };
+}
diff --git a/src/lib/ide/shell-options.ts b/src/lib/ide/shell-options.ts
new file mode 100644
index 0000000..c776fc2
--- /dev/null
+++ b/src/lib/ide/shell-options.ts
@@ -0,0 +1,24 @@
+/** Which parts of the IDE shell render. Embeds narrow this; the playground takes `FULL_SHELL`. */
+export type ShellOptions = {
+ header: boolean;
+ fileTree: boolean;
+ search: boolean;
+ editor: boolean;
+ terminal: boolean;
+ preview: boolean;
+ /** Settings, bug report and zen toggle. */
+ tools: boolean;
+ /** The beforeunload prompt, which an embed would fire inside the host's page. */
+ leaveGuard: boolean;
+};
+
+export const FULL_SHELL: ShellOptions = {
+ header: true,
+ fileTree: true,
+ search: true,
+ editor: true,
+ terminal: true,
+ preview: true,
+ tools: true,
+ leaveGuard: true
+};
diff --git a/src/lib/utils/platform.ts b/src/lib/utils/platform.ts
index 8c2a34c..9f576e3 100644
--- a/src/lib/utils/platform.ts
+++ b/src/lib/utils/platform.ts
@@ -9,3 +9,16 @@ export function isIos(): boolean {
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
);
}
+
+/** Why BrowserPod cannot run here, or null when it can. */
+export type PodBlocker = 'unsupported-browser' | 'not-isolated';
+
+/**
+ * SharedArrayBuffer is only exposed on cross-origin isolated pages, and isolation is inherited
+ * from the top-level document, so an embed fails here when its host page omits the headers.
+ */
+export function podBlocker(): PodBlocker | null {
+ if (typeof Atomics?.waitAsync !== 'function') return 'unsupported-browser';
+ if (!globalThis.crossOriginIsolated) return 'not-isolated';
+ return null;
+}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 8dd76a9..9550717 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -19,9 +19,13 @@
// flyouts) the rest of the time.
let ribbonAboveTour = $derived(stepperState.open && stepperState.step === 6);
+ // Embeds render inside a host page, where none of the app chrome belongs.
+ let isEmbed = $derived($page.route.id?.startsWith('/embed') ?? false);
+
// Show on the landing surfaces (Home, /agents, bare /ide) and during tour step 6.
let showRibbon = $derived(
!zenState.on &&
+ !isEmbed &&
(ribbonAboveTour ||
$page.route.id === '/' ||
$page.route.id === '/agents' ||
@@ -85,9 +89,11 @@
+ Pass ?repo=<github url> or
+ ?framework=<id>, optionally with
+ &view=files,editor,terminal,preview.
+
+
+
+{/if}
diff --git a/static/_headers b/static/_headers
index 90cb508..32b99a2 100644
--- a/static/_headers
+++ b/static/_headers
@@ -1,6 +1,10 @@
-# Enable Cross-Origin Isolation and allow embedding from browserpod.io (and CI previews)
+# Cross-origin isolation for SharedArrayBuffer. Do not remove.
/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: cross-origin
+
+# Takes an API key, so it stays unframable. Every other route carries no frame-ancestors, which
+# is what lets a host page embed /embed.
+/agents*
Content-Security-Policy: frame-ancestors 'self' https://browserpod.io https://*.browserpod.io https://*.browserpod.pages.dev
From cb0b011815eced36dc01f84ad3a2531d30f5608c Mon Sep 17 00:00:00 2001
From: rijulshrestha
Date: Mon, 14 Sep 2026 13:56:45 +0100
Subject: [PATCH 2/2] feat: make CLI agents embeddable through /embed
---
docs/embedding.md | 27 +++++++---
src/lib/agents/codex.ts | 14 ++++-
src/lib/agents/session.svelte.ts | 7 ++-
.../components/agents/AgentErrorCard.svelte | 16 +++---
.../components/agents/AgentLoadingCard.svelte | 16 +++---
src/lib/components/agents/AgentShell.svelte | 53 +++++++++++--------
.../agents/CredentialGateOverlay.svelte | 2 +-
src/lib/embed/options.ts | 9 +++-
src/routes/embed/+page.svelte | 36 +++++++++----
9 files changed, 118 insertions(+), 62 deletions(-)
diff --git a/docs/embedding.md b/docs/embedding.md
index 797907a..09557fb 100644
--- a/docs/embedding.md
+++ b/docs/embedding.md
@@ -1,6 +1,6 @@
# Embedding BrowserCode
-BrowserCode runs entirely in the browser, so an embed is our page inside your iframe.
+BrowserCode runs entirely in the browser. An embed is our page inside your iframe.
```html
```
-## Your page must be cross-origin isolated
+## Required headers
-This is the one requirement we cannot satisfy for you. BrowserPod needs `SharedArrayBuffer`, which browsers only expose on cross-origin isolated pages, and isolation is inherited from the top-level document. The page doing the embedding has to send both headers itself:
+BrowserPod needs `SharedArrayBuffer`, which browsers expose only on cross-origin isolated pages. Isolation is inherited from the top-level document, so the embedding page must send both headers itself:
```
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
```
-and the iframe needs `allow="cross-origin-isolated"`. Without all three the embed loads and then reports that the headers are missing.
+The iframe must carry `allow="cross-origin-isolated"`. Without all three the embed reports that the headers are missing.
-Be aware that `require-corp` blocks cross-origin resources that do not opt in, which can break images, fonts, analytics and third-party iframes elsewhere on your page. Deploy `Cross-Origin-Embedder-Policy-Report-Only` first to see what would break, and consider `credentialless` instead, which clears most of it. If you cannot enable these headers at all, open BrowserCode in a new tab instead, which needs nothing from your page.
+`require-corp` blocks cross-origin resources on your page that do not send `Cross-Origin-Resource-Policy` or use CORS.
-## Options
+## Parameters
| Parameter | Value |
| ----------- | ----------------------------------------------------------------------------- |
| `repo` | Any GitHub URL or `owner/repo`, optionally `.../tree//` |
| `framework` | A template id (`vite`, `react`, `svelte`, `vue`, `nextjs`, `nuxt`, `express`) |
+| `agent` | A CLI agent id (`claude`, `codex`) |
| `view` | Comma separated: `files`, `search`, `editor`, `terminal`, `preview` |
-`view` defaults to every pane. Pass `repo` or `framework`, not both; with neither, the default template boots. Controls that stop making sense are dropped automatically, so a preview-only embed has no hide button and no port badge.
+## Behaviour
+
+- Pass one of `agent`, `repo` or `framework`. With none, the default template boots.
+- `view` defaults to every pane.
+- Agents are terminal first, so `view` only decides whether the preview pane comes with it.
+- One agent session per browser. A second embed of the same agent, or the same agent open in another tab, shows the duplicate session dialog.
+- `codex` asks for an OpenAI API key inside the frame.
+- `claude` opens a new tab for OAuth sign-in.
+- Controls without meaning are omitted: a preview-only embed has no hide button and no port badge.
+
+## Examples
```
/embed?repo=https://github.com/user/repo/tree/main/examples/demo
/embed?framework=vite&view=preview
/embed?framework=nextjs&view=files,editor,terminal
+/embed?agent=claude
+/embed?agent=codex&view=terminal
```
diff --git a/src/lib/agents/codex.ts b/src/lib/agents/codex.ts
index 58118f8..1ad3c6c 100644
--- a/src/lib/agents/codex.ts
+++ b/src/lib/agents/codex.ts
@@ -22,12 +22,22 @@ code_mode = false
code_mode_only = false
`;
+/** Embedded in a third-party frame the browser may partition storage away, or refuse it outright. */
export function getCodexApiKey(): string | null {
- return localStorage.getItem(API_KEY_STORAGE);
+ try {
+ return localStorage.getItem(API_KEY_STORAGE);
+ } catch (error) {
+ console.warn('Could not read the stored API key:', error);
+ return null;
+ }
}
export function setCodexApiKey(key: string): void {
- localStorage.setItem(API_KEY_STORAGE, key);
+ try {
+ localStorage.setItem(API_KEY_STORAGE, key);
+ } catch (error) {
+ console.warn('Could not persist the API key:', error);
+ }
}
/** Codex reads the key from its environment, so it is only injectable at launch. */
diff --git a/src/lib/agents/session.svelte.ts b/src/lib/agents/session.svelte.ts
index 035ddd8..0ad49ac 100644
--- a/src/lib/agents/session.svelte.ts
+++ b/src/lib/agents/session.svelte.ts
@@ -33,7 +33,10 @@ export class AgentSession {
private releaseLock: () => void = () => {};
private disposeLeaveGuard: () => void = () => {};
- constructor(requestedTool: string | undefined) {
+ private readonly leaveGuard: boolean;
+
+ constructor(requestedTool: string | undefined, options: { leaveGuard?: boolean } = {}) {
+ this.leaveGuard = options.leaveGuard ?? true;
this.id = resolveToolId(requestedTool);
// resolveToolId only ever returns an id that is in toolItems, so this always resolves.
this.tool = toolItems.find((item) => item.id === this.id)!;
@@ -58,7 +61,7 @@ export class AgentSession {
this.lock = 'held';
// Only warn on tab close/refresh/back-button once there is work here to lose.
- this.disposeLeaveGuard = installLeaveGuard();
+ if (this.leaveGuard) this.disposeLeaveGuard = installLeaveGuard();
// Covers pod boot, the image streaming in, and any warm-up probe.
this.gate?.begin();
diff --git a/src/lib/components/agents/AgentErrorCard.svelte b/src/lib/components/agents/AgentErrorCard.svelte
index de8d0a6..2e7e450 100644
--- a/src/lib/components/agents/AgentErrorCard.svelte
+++ b/src/lib/components/agents/AgentErrorCard.svelte
@@ -12,7 +12,7 @@
/** The boot failure, verbatim — vague "something went wrong" copy helps nobody debug a pod. */
message: string;
onRetry: () => void;
- onCancel: () => void;
+ onCancel?: () => void;
} = $props();
@@ -45,12 +45,14 @@
-
- Back to agents
-
+ {#if onCancel}
+
+ Back to agents
+
+ {/if}
void;
+ onCancel?: () => void;
} = $props();
/** The bare host reads better as link text than the full URL. */
@@ -72,12 +72,14 @@