diff --git a/apps/soundcraft-web/Cargo.toml b/apps/soundcraft-web/Cargo.toml
--- a/apps/soundcraft-web/Cargo.toml
+++ b/apps/soundcraft-web/Cargo.toml
@@ -27,4 +27,4 @@ js-sys = "0.3"
 eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
 wasm-bindgen = "0.2"
 wasm-bindgen-futures = "0.4"
-web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "CssStyleDeclaration"] }
+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "CssStyleDeclaration", "MessageEvent", "UrlSearchParams"] }
diff --git a/apps/soundcraft-web/src/bridge.rs b/apps/soundcraft-web/src/bridge.rs
new file mode 100644
--- /dev/null
+++ b/apps/soundcraft-web/src/bridge.rs
@@ -0,0 +1,162 @@
+//! The host bridge's decisions, kept free of `web_sys` so they are tested natively: which parent
+//! origin the bridge may talk to, and whether an opened document fits.
+
+/// The origin the bridge posts to and hears from, or `None` for no bridge (saves download).
+///
+/// - `own`: this page's origin.
+/// - `parent`: the parent window's origin, `None` when reading it threw (a cross-origin parent).
+/// - `allowed`: the `?host_origin=` query value, an origin the embedding page vouches for.
+///
+/// A same-origin parent is accepted. A cross-origin parent is accepted only when `allowed` is a
+/// valid URL origin (`scheme://host[:port]`, nothing else); messages then go to that exact origin,
+/// so the browser drops them if the parent is anything else. Opaque origins (`"null"`) never match.
+pub fn target_origin(own: &str, parent: Option<&str>, allowed: Option<&str>) -> Option<String> {
+    if let Some(parent) = parent
+        && parent == own
+        && is_origin(own)
+    {
+        return Some(own.to_string());
+    }
+    canonical_origin(allowed?.trim())
+}
+
+/// `s` as a browser serialises an origin (lowercase scheme and host, no default port), or `None`
+/// when it isn't one [`is_origin`] accepts. `MessageEvent.origin` is compared with this string
+/// exactly, so `https://Files.Example:443` must become `https://files.example` to ever match.
+pub fn canonical_origin(s: &str) -> Option<String> {
+    if !is_origin(s) {
+        return None;
+    }
+    let (scheme, rest) = s.split_once("://")?;
+    let scheme = scheme.to_ascii_lowercase();
+    let (host, port) = match rest.split_once(':') {
+        Some((h, p)) => (h, Some(p.parse::<u16>().ok()?)),
+        None => (rest, None),
+    };
+    let host = host.to_ascii_lowercase();
+    match (scheme.as_str(), port) {
+        ("https", Some(443)) | ("http", Some(80)) | (_, None) => Some(format!("{scheme}://{host}")),
+        (_, Some(port)) => Some(format!("{scheme}://{host}:{port}")),
+    }
+}
+
+/// True for a URL origin: `http(s)://host[:port]` with no path, query, fragment or credentials,
+/// and only in forms whose browser serialisation [`canonical_origin`] can produce: an ASCII host
+/// (an IDN must be given in punycode, `xn--…`), and a numeric host only as a dotted-decimal IPv4.
+pub fn is_origin(s: &str) -> bool {
+    let Some((scheme, rest)) = s.split_once("://") else { return false };
+    if !(scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")) || rest.is_empty() {
+        return false;
+    }
+    // IPv6 literals aren't accepted; name the host instead.
+    if rest.contains(['/', '?', '#', '@', ' ', '\\', '[', ']']) {
+        return false;
+    }
+    let (host, port) = match rest.split_once(':') {
+        Some((h, p)) => (h, Some(p)),
+        None => (rest, None),
+    };
+    if host.is_empty() || !host.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_')) {
+        return false;
+    }
+    // Browsers rewrite numeric hosts (`0x7f.1`, `127.1`, `010.0.0.1`) to dotted decimal; accept
+    // only the form they would produce.
+    let last = host.trim_end_matches('.').rsplit('.').next().unwrap_or("");
+    if last.bytes().all(|b| b.is_ascii_digit()) || last.to_ascii_lowercase().starts_with("0x") {
+        let parts: Vec<&str> = host.split('.').collect();
+        let decimal = |p: &&str| !p.is_empty() && (*p == "0" || !p.starts_with('0')) && p.parse::<u8>().is_ok();
+        if parts.len() != 4 || !parts.iter().all(decimal) {
+            return false;
+        }
+    }
+    match port {
+        Some(p) => p.bytes().all(|b| b.is_ascii_digit()) && p.parse::<u16>().is_ok(),
+        None => true,
+    }
+}
+
+/// Refuse `soundcraft:open` bytes (`len`) when they, plus the `queued` bytes still waiting for the
+/// next frame, exceed `cap`. Checked before the bytes are copied; draining the queue frees room.
+pub fn check_open(name: &str, queued: f64, len: f64, cap: u64) -> Result<(), String> {
+    if queued + len > cap as f64 {
+        return Err(format!("{name}: file is too large to open ({len} bytes, {queued} already waiting; the limit is {cap} bytes)"));
+    }
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const OWN: &str = "https://app.example";
+
+    #[test]
+    fn same_origin_parent_enables_the_bridge() {
+        assert_eq!(target_origin(OWN, Some(OWN), None).as_deref(), Some(OWN));
+    }
+
+    #[test]
+    fn cross_origin_parent_disables_the_bridge() {
+        // Reading a cross-origin parent's origin throws (None) or names another origin.
+        assert_eq!(target_origin(OWN, None, None), None);
+        assert_eq!(target_origin(OWN, Some("https://evil.example"), None), None);
+        assert_eq!(target_origin(OWN, Some("null"), None), None);
+        assert_eq!(target_origin("null", Some("null"), None), None);
+    }
+
+    #[test]
+    fn host_origin_parameter_must_be_an_exact_origin() {
+        let files = "https://files.example:8443";
+        assert_eq!(target_origin(OWN, None, Some(files)).as_deref(), Some(files));
+        for bad in
+            ["*", "null", "files.example", "https://files.example/", "https://a@files.example", "javascript://x", "https://", "https://x:99999"]
+        {
+            assert_eq!(target_origin(OWN, None, Some(bad)), None, "{bad}");
+        }
+    }
+
+    #[test]
+    fn host_origin_is_stored_as_the_browser_serialises_it() {
+        // `MessageEvent.origin` is lowercase with no default port; the stored origin must match it.
+        for (given, stored) in [
+            ("https://Files.Example", "https://files.example"),
+            ("HTTPS://FILES.EXAMPLE", "https://files.example"),
+            ("https://files.example:443", "https://files.example"),
+            ("http://files.example:80", "http://files.example"),
+            ("http://files.example:443", "http://files.example:443"),
+            ("https://files.example:80", "https://files.example:80"),
+            ("https://files.example:08443", "https://files.example:8443"),
+            ("https://xn--bcher-kva.example", "https://xn--bcher-kva.example"),
+            ("http://127.0.0.1:8080", "http://127.0.0.1:8080"),
+            ("  https://Files.Example:443  ", "https://files.example"),
+        ] {
+            assert_eq!(target_origin(OWN, None, Some(given)).as_deref(), Some(stored), "{given}");
+        }
+    }
+
+    #[test]
+    fn host_origin_a_browser_would_serialise_differently_is_refused() {
+        for bad in [
+            "https://b\u{fc}cher.example",
+            "https://files.example:",
+            "https://0x7f.0.0.1",
+            "https://127.1",
+            "https://010.0.0.1",
+            "https://256.0.0.1",
+            "https://files%2eexample",
+            "https://[::1]",
+        ] {
+            assert_eq!(target_origin(OWN, None, Some(bad)), None, "{bad}");
+        }
+    }
+
+    #[test]
+    fn open_cap_counts_documents_still_waiting() {
+        assert!(check_open("a.scraftz", 0.0, 100.0, 100).is_ok());
+        let e = check_open("a.scraftz", 0.0, 101.0, 100).unwrap_err();
+        assert!(e.contains("too large") && e.contains("a.scraftz"));
+        // Two documents that each fit are refused together until the first is drained.
+        assert!(check_open("b.mid", 60.0, 60.0, 100).is_err());
+        assert!(check_open("b.mid", 0.0, 60.0, 100).is_ok());
+    }
+}
diff --git a/apps/soundcraft-web/src/host.rs b/apps/soundcraft-web/src/host.rs
new file mode 100644
--- /dev/null
+++ b/apps/soundcraft-web/src/host.rs
@@ -0,0 +1,170 @@
+//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
+//! SoundCraft in an `<iframe>` (such as a file manager). It follows GridCraft's and WordCraft's:
+//!
+//! - `soundcraft:ready` (to the parent): `{ version }`, the app is listening; send a file now.
+//! - `soundcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
+//!   `Uint8Array`. It is handled like a file dropped on the window: a session (`.scraftz` bundle
+//!   or bare `.scraft`) opens, MIDI and audio import.
+//! - `soundcraft:save` (to the parent): `{ name, bytes }` (a transferred `ArrayBuffer`) for every
+//!   file SoundCraft would otherwise download: Save (the `<name>.scraftz` bundle, which marks the
+//!   session saved), Save As, Save a Copy, bounces and the exports.
+//! - `soundcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
+//! - `soundcraft:failed` (to the parent): `{ error }` when the app couldn't start, or when a
+//!   `soundcraft:open` document (or the documents waiting to open) is over `MAX_OPEN_BYTES` (1 GiB,
+//!   the session-bundle cap).
+//!
+//! The bridge is installed only for a same-origin parent, or for a parent on the origin named by
+//! `?host_origin=` (a bare `scheme://host[:port]` origin); messages go to that exact origin and only
+//! the parent window on it is heard. A cross-origin parent without `host_origin` gets no bridge:
+//! saves download as usual. In host mode the page
+//! starts on an empty session instead of the demo. SoundCraft has no user name, so there is no
+//! `?author=`.
+
+use std::cell::{Cell, RefCell};
+use std::rc::Rc;
+
+use wasm_bindgen::JsCast as _;
+use wasm_bindgen::JsValue;
+use wasm_bindgen::closure::Closure;
+
+thread_local! {
+    /// The bridge the engine's delivery hook posts to (a plain `fn` can't capture it).
+    static BRIDGE: RefCell<Option<Bridge>> = const { RefCell::new(None) };
+}
+
+/// Files the parent sent, waiting for the next frame: `(name, bytes)`.
+pub type Received = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
+
+#[derive(Clone)]
+pub struct Bridge {
+    window: web_sys::Window,
+    parent: web_sys::Window,
+    origin: String,
+    /// The unsaved-changes state last reported to the parent.
+    reported: Rc<Cell<Option<bool>>>,
+    pub received: Received,
+}
+
+impl Bridge {
+    /// `None` when the page isn't framed, or the parent is cross-origin and not the origin
+    /// `host_origin` allows (see [`crate::bridge::target_origin`]).
+    pub fn new(host_origin: Option<&str>) -> Option<Self> {
+        let window = web_sys::window()?;
+        let parent = window.parent().ok().flatten()?;
+        if js_sys::Object::is(&parent, &window) {
+            return None;
+        }
+        let own = window.location().origin().ok()?;
+        // Reading a cross-origin parent's location throws: treat that as cross-origin.
+        let parent_origin = parent.location().origin().ok();
+        let origin = crate::bridge::target_origin(&own, parent_origin.as_deref(), host_origin)?;
+        Some(Self { window, parent, origin, reported: Rc::default(), received: Rc::default() })
+    }
+
+    /// Hear files from the parent and make [`deliver`] post to it.
+    pub fn connect(&self, ctx: egui::Context) {
+        self.listen(ctx);
+        BRIDGE.with(|b| *b.borrow_mut() = Some(self.clone()));
+    }
+
+    /// Report unsaved changes (once a frame).
+    pub fn report_dirty(&self, dirty: bool) {
+        if self.reported.get() != Some(dirty) && self.send(&[("type", "soundcraft:dirty".into()), ("dirty", dirty.into())]) {
+            self.reported.set(Some(dirty));
+        }
+    }
+
+    pub fn post_ready(&self) {
+        self.send(&[("type", "soundcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
+    }
+
+    pub fn post_failed(&self, error: &str) {
+        self.send(&[("type", "soundcraft:failed".into()), ("error", error.into())]);
+    }
+
+    fn post_save(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
+        let buffer = js_sys::Uint8Array::from(bytes).buffer();
+        let message = message(&[("type", "soundcraft:save".into()), ("name", name.into()), ("bytes", buffer.clone().into())])
+            .ok_or_else(|| "couldn't build the message".to_string())?;
+        self.parent
+            .post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
+            .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
+    }
+
+    fn listen(&self, ctx: egui::Context) {
+        let parent = self.parent.clone();
+        let origin = self.origin.clone();
+        let received = self.received.clone();
+        let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
+            if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
+                return;
+            }
+            let data = event.data();
+            if string(&data, "type").as_deref() != Some("soundcraft:open") {
+                return;
+            }
+            let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Session.scraftz".to_string());
+            let Some(array) = byte_array(&data) else { return };
+            // The cap covers this document plus any still waiting for the next frame.
+            let queued: f64 = received.borrow().iter().map(|(_, b)| b.len() as f64).sum();
+            if let Err(error) = crate::bridge::check_open(&name, queued, f64::from(array.byte_length()), MAX_OPEN_BYTES) {
+                post_failed_to(&parent, &origin, &error);
+                return;
+            }
+            let bytes = array.to_vec();
+            received.borrow_mut().push((name, bytes));
+            ctx.request_repaint();
+        });
+        if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
+            // The listener lives as long as the page.
+            on_message.forget();
+        }
+    }
+
+    /// Post a message to the parent; false if it couldn't be sent.
+    fn send(&self, fields: &[(&str, JsValue)]) -> bool {
+        message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
+    }
+}
+
+/// The engine's delivery hook: the parent when connected, else `fallback` (the download).
+pub fn deliver(name: &str, bytes: &[u8], fallback: fn(&str, &[u8]) -> Result<(), String>) -> Result<(), String> {
+    match BRIDGE.with(|b| b.borrow().clone()) {
+        Some(bridge) => bridge.post_save(name, bytes),
+        None => fallback(name, bytes),
+    }
+}
+
+fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
+    let m = js_sys::Object::new();
+    for (key, value) in fields {
+        js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
+    }
+    Some(m)
+}
+
+fn string(data: &JsValue, key: &str) -> Option<String> {
+    js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
+}
+
+/// `bytes` (an `ArrayBuffer` or `Uint8Array`) as a view, not yet copied.
+fn byte_array(data: &JsValue) -> Option<js_sys::Uint8Array> {
+    let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
+    if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
+        Some(array.clone())
+    } else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
+        Some(js_sys::Uint8Array::new(&bytes))
+    } else {
+        None
+    }
+}
+
+fn post_failed_to(parent: &web_sys::Window, origin: &str, error: &str) {
+    if let Some(m) = message(&[("type", "soundcraft:failed".into()), ("error", error.into())]) {
+        let _ = parent.post_message(&m, origin);
+    }
+}
+
+/// Most bytes `soundcraft:open` takes: the same cap a session bundle may unpack to
+/// ([`soundcraft_engine::io::MAX_BUNDLE_BYTES`]), checked before the bytes are copied.
+pub(crate) const MAX_OPEN_BYTES: u64 = soundcraft_engine::io::MAX_BUNDLE_BYTES;
diff --git a/apps/soundcraft-web/src/main.rs b/apps/soundcraft-web/src/main.rs
--- a/apps/soundcraft-web/src/main.rs
+++ b/apps/soundcraft-web/src/main.rs
@@ -15,9 +15,14 @@
 //!   playback is heard.
 //!
 //! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?empty` starts with an
-//! empty session instead of the demo.
+//! empty session instead of the demo; `?host=parent` hands open and save to a same-origin parent
+//! page, or to the parent on the origin `?host_origin=` names (`host`).
 #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
 
+#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
+mod bridge;
+#[cfg(target_arch = "wasm32")]
+mod host;
 #[cfg(target_arch = "wasm32")]
 mod web;
 
diff --git a/apps/soundcraft-web/src/web.rs b/apps/soundcraft-web/src/web.rs
--- a/apps/soundcraft-web/src/web.rs
+++ b/apps/soundcraft-web/src/web.rs
@@ -14,7 +14,7 @@ const LOADING_ID: &str = "soundcraft_loading";
 
 pub fn start() {
     eframe::WebLogger::init(log::LevelFilter::Info).ok();
-    io::set_deliver(download);
+    io::set_deliver(|name, bytes| crate::host::deliver(name, bytes, download));
     wasm_bindgen_futures::spawn_local(async {
         let Some(document) = web_sys::window().and_then(|w| w.document()) else {
             log::error!("no document");
@@ -24,6 +24,11 @@ pub fn start() {
             log::error!("missing <canvas id=\"{CANVAS_ID}\">");
             return;
         };
+        // `?host=parent`: a same-origin page framing SoundCraft opens files in it and stores what
+        // it saves (`host`).
+        let bridge =
+            if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new(query_param("host_origin").as_deref()) } else { None };
+        let app_bridge = bridge.clone();
         let mut options = eframe::WebOptions::default();
         if query().contains("webgl")
             && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
@@ -38,13 +43,24 @@ pub fn start() {
                     if let Some(rs) = &cc.wgpu_render_state {
                         log::info!("soundcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
                     }
-                    let engine = if query().contains("empty") { Engine::default() } else { soundcraft_engine::demo::demo_engine() };
+                    let engine =
+                        if app_bridge.is_some() || query().contains("empty") { Engine::default() } else { soundcraft_engine::demo::demo_engine() };
                     let player = soundcraft_playback::Player::new(Arc::new(engine.session().clone()));
                     let app = SoundApp::new(engine, Some(player), Services::default());
-                    Ok(Box::new(WebShell { app, inbox: Rc::default(), samples: Rc::default() }))
+                    if let Some(bridge) = &app_bridge {
+                        bridge.connect(cc.egui_ctx.clone());
+                    }
+                    Ok(Box::new(WebShell { app, inbox: Rc::default(), samples: Rc::default(), bridge: app_bridge }))
                 }),
             )
             .await;
+        // Ready only once the runner started; otherwise the parent hears why it didn't.
+        if let Some(bridge) = &bridge {
+            match &result {
+                Ok(()) => bridge.post_ready(),
+                Err(e) => bridge.post_failed(&format!("{e:?}")),
+            }
+        }
         if let Some(el) = document.get_element_by_id(LOADING_ID) {
             match result {
                 Ok(()) => el.remove(),
@@ -58,6 +74,10 @@ fn query() -> String {
     web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
 }
 
+fn query_param(key: &str) -> Option<String> {
+    web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
+}
+
 /// What a file read from the browser is for.
 #[derive(Debug, Clone, Copy)]
 enum Purpose {
@@ -115,6 +135,7 @@ struct WebShell {
     app: SoundApp,
     inbox: Inbox,
     samples: SampleInbox,
+    bridge: Option<crate::host::Bridge>,
 }
 
 impl WebShell {
@@ -196,6 +217,11 @@ impl eframe::App for WebShell {
     fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
         self.take_drops(ctx);
         self.take_path_prompt(ctx);
+        if let Some(bridge) = &self.bridge {
+            // Files from the host page are handled like dropped ones.
+            let received = std::mem::take(&mut *bridge.received.borrow_mut());
+            self.inbox.borrow_mut().extend(received.into_iter().map(|(name, bytes)| (Purpose::of(&name), name, bytes)));
+        }
         let pending = std::mem::take(&mut *self.inbox.borrow_mut());
         for (purpose, name, bytes) in pending {
             self.handle(purpose, name, bytes);
@@ -217,6 +243,9 @@ impl eframe::App for WebShell {
 
     fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
         self.app.ui(ui);
+        if let Some(bridge) = &self.bridge {
+            bridge.report_dirty(self.app.engine.is_dirty());
+        }
     }
 }
 
diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs
--- a/crates/engine/src/io.rs
+++ b/crates/engine/src/io.rs
@@ -19,7 +19,7 @@ mod session;
 mod strip;
 
 use bundle::BundleLimits;
-pub use bundle::{BUNDLE_EXTENSION, open_session_bytes, session_bundle};
+pub use bundle::{BUNDLE_EXTENSION, MAX_BUNDLE_BYTES, open_session_bytes, session_bundle};
 pub use import::{DecodedImport, add_source, decode_import, import_audio_bytes, load_sampler_bytes, relink_videos};
 pub(crate) use import::{MAX_IMPORT_SAMPLES, at_rate, place_decoded};
 pub use midi::{export_midi, import_midi_bytes};
diff --git a/crates/engine/src/io/bundle.rs b/crates/engine/src/io/bundle.rs
--- a/crates/engine/src/io/bundle.rs
+++ b/crates/engine/src/io/bundle.rs
@@ -17,7 +17,10 @@ pub(super) struct BundleLimits {
     pub(super) bytes: u64,
 }
 
-const BUNDLE_LIMITS: BundleLimits = BundleLimits { entries: 10_000, bytes: 1 << 30 };
+/// Most bytes a bundle may unpack to; hosts check an upload against it before copying it.
+pub const MAX_BUNDLE_BYTES: u64 = 1 << 30;
+
+const BUNDLE_LIMITS: BundleLimits = BundleLimits { entries: 10_000, bytes: MAX_BUNDLE_BYTES };
 
 /// The session as a bundle: `<name>.scraft` plus every source's audio as 32-bit float WAV under
 /// `Audio Files/` (stored, not compressed: audio barely compresses). Returns the bytes and the
