diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -129,6 +129,18 @@ cd apps/gridcraft-web && trunk serve --release     # http://127.0.0.1:8771  (?sa
 You need [trunk](https://trunk-rs.github.io/trunk/) and the `wasm32-unknown-unknown` target. Open uses the
 browser's file picker (dropping files works too) and Save downloads the workbook.
 
+A same-origin parent can embed the editor with `?host=parent` and open complete native JSON
+with `gridcraft:open` (`name: "workbook.json"`, `bytes: ArrayBuffer`). After `gridcraft:ready`,
+send `gridcraft:export-native` with an opaque string `requestId`; `gridcraft:native-exported`
+returns that ID, the exported `document` and `revision`, and complete native JSON `bytes`, or `error`.
+The revision identifies the state after any pending draft was committed. Export commits pending drafts
+through the engine and leaves the unsaved state intact: the host remains responsible for durable
+storage. `gridcraft:changed` reports every active document revision, including further edits while
+already dirty. `gridcraft:validate-native` (`requestId`, `bytes`) returns
+`gridcraft:native-validated` (`requestId`, `valid`, `error`) without changing the document.
+These messages are accepted only from the same-origin parent. They do not grant permission to
+store a document or turn the ordinary XLSX Save action into a durable acknowledgement.
+
 ### Ribbon access keys
 
 Press and release **left Alt** on Windows/Linux/BSD, or press **F10** on any platform,
diff --git a/apps/gridcraft-web/src/host.rs b/apps/gridcraft-web/src/host.rs
--- a/apps/gridcraft-web/src/host.rs
+++ b/apps/gridcraft-web/src/host.rs
@@ -8,7 +8,15 @@
 //!   file GridCraft would otherwise download: Save and Save As (an .xlsx workbook) and Export
 //!   as PDF. The parent stores it and reports failures itself.
 //! - `gridcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
+//! - `gridcraft:changed` (to the parent): `{ document, revision, dirty }` when the active
+//!   workbook changes. Document and revision are opaque strings; edits while already dirty
+//!   still notify the host, so it can debounce a fresh native export.
 //! - `gridcraft:failed` (to the parent): `{ error }` when the app couldn't start.
+//! - `gridcraft:export-native` (from the parent): `{ requestId }`; replies with
+//!   `gridcraft:native-exported` `{ requestId, document, revision, bytes }` (native JSON ArrayBuffer), or `{ requestId, error }`.
+//!   Pending drafts are committed first. Export does not claim a durable save or clear dirty state.
+//! - `gridcraft:validate-native` (from the parent): `{ requestId, bytes }`; replies with
+//!   `gridcraft:native-validated` `{ requestId, valid, error? }` without changing the document.
 //!
 //! Only the parent window on the page's own origin is heard or answered. In host mode the page
 //! starts with an empty workbook instead of a sample, and the parent guards leaving the page.
@@ -28,6 +36,11 @@ const WORKBOOK_EXTS: &[&str] = &["xlsx"];
 /// The longest user name taken from `?author=`.
 const MAX_AUTHOR_CHARS: usize = 200;
 
+enum NativeRequest {
+    Export(String),
+    Validate(String, Vec<u8>),
+}
+
 #[derive(Clone)]
 pub struct Bridge {
     window: web_sys::Window,
@@ -37,6 +50,8 @@ pub struct Bridge {
     saved: Rc<RefCell<Vec<String>>>,
     /// The unsaved-changes state last reported to the parent.
     reported: Rc<Cell<Option<bool>>>,
+    native: Rc<RefCell<Vec<NativeRequest>>>,
+    reported_revision: Rc<Cell<Option<(u64, u64)>>>,
 }
 
 impl Bridge {
@@ -48,7 +63,7 @@ impl Bridge {
             return None;
         }
         let origin = window.location().origin().ok()?;
-        Some(Self { window, parent, origin, saved: Rc::default(), reported: Rc::default() })
+        Some(Self { window, parent, origin, saved: Rc::default(), reported: Rc::default(), native: Rc::default(), reported_revision: Rc::default() })
     }
 
     /// Send saved and exported files to the parent instead of downloading them, and hear files
@@ -82,6 +97,42 @@ impl Bridge {
     /// After each frame: a workbook handed to the parent this frame counts as saved under the
     /// name it was saved as, and the parent hears when unsaved changes appear or go away.
     pub fn sync(&self, app: &mut SheetApp) {
+        let requests = std::mem::take(&mut *self.native.borrow_mut());
+        for request in requests {
+            match request {
+                NativeRequest::Export(id) => match crate::native_document::export(app) {
+                    Ok(snapshot) => {
+                        let buffer = js_sys::Uint8Array::from(snapshot.bytes.as_slice()).buffer();
+                        if let Some(message) = message(&[
+                            ("type", "gridcraft:native-exported".into()),
+                            ("requestId", id.as_str().into()),
+                            ("document", snapshot.document.as_str().into()),
+                            ("revision", snapshot.revision.as_str().into()),
+                            ("bytes", buffer.clone().into()),
+                        ]) {
+                            let _ = self.parent.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer));
+                        }
+                    }
+                    Err(error) => {
+                        self.send(&[
+                            ("type", "gridcraft:native-exported".into()),
+                            ("requestId", id.as_str().into()),
+                            ("error", error.as_str().into()),
+                        ]);
+                    }
+                },
+                NativeRequest::Validate(id, bytes) => {
+                    let result = crate::native_document::validate(&bytes);
+                    let error = result.as_ref().err().map(String::as_str).unwrap_or("");
+                    self.send(&[
+                        ("type", "gridcraft:native-validated".into()),
+                        ("requestId", id.as_str().into()),
+                        ("valid", result.is_ok().into()),
+                        ("error", error.into()),
+                    ]);
+                }
+            }
+        }
         let saved = std::mem::take(&mut *self.saved.borrow_mut());
         if let Some(name) = saved.iter().rev().find(|name| is_workbook(name))
             && let Some(doc) = app.session.active_mut()
@@ -95,6 +146,18 @@ impl Bridge {
         if self.reported.get() != Some(dirty) && self.send(&[("type", "gridcraft:dirty".into()), ("dirty", dirty.into())]) {
             self.reported.set(Some(dirty));
         }
+        let revision = app.session.active().map(|doc| (doc.uid, doc.revision));
+        if revision != self.reported_revision.get() {
+            if let Some((document, revision)) = revision {
+                self.send(&[
+                    ("type", "gridcraft:changed".into()),
+                    ("document", document.to_string().into()),
+                    ("revision", revision.to_string().into()),
+                    ("dirty", dirty.into()),
+                ]);
+            }
+            self.reported_revision.set(revision);
+        }
     }
 
     fn post_save(&self, path: &str, bytes: &[u8]) -> Result<(), String> {
@@ -112,12 +175,44 @@ impl Bridge {
     fn listen(&self, inbox: Inbox, ctx: egui::Context) {
         let parent = self.parent.clone();
         let origin = self.origin.clone();
+        let bridge = self.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("gridcraft:open") {
+            let kind = string(&data, "type");
+            if matches!(kind.as_deref(), Some("gridcraft:export-native" | "gridcraft:validate-native")) {
+                let Some(id) = string(&data, "requestId").filter(|id| !id.is_empty() && id.len() <= 128) else { return };
+                let response =
+                    if kind.as_deref() == Some("gridcraft:export-native") { "gridcraft:native-exported" } else { "gridcraft:native-validated" };
+                if bridge.native.borrow().len() >= 8 {
+                    bridge.send(&[
+                        ("type", response.into()),
+                        ("requestId", id.as_str().into()),
+                        ("valid", false.into()),
+                        ("error", "The native document request queue is full.".into()),
+                    ]);
+                    return;
+                }
+                let request = if kind.as_deref() == Some("gridcraft:export-native") {
+                    NativeRequest::Export(id)
+                } else if let Some(bytes) = bytes(&data) {
+                    NativeRequest::Validate(id, bytes)
+                } else {
+                    bridge.send(&[
+                        ("type", response.into()),
+                        ("requestId", id.as_str().into()),
+                        ("valid", false.into()),
+                        ("error", "Native workbook bytes are required.".into()),
+                    ]);
+                    return;
+                };
+                bridge.native.borrow_mut().push(request);
+                ctx.request_repaint();
+                return;
+            }
+            if kind.as_deref() != Some("gridcraft:open") {
                 return;
             }
             let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Book.xlsx".to_string());
diff --git a/apps/gridcraft-web/src/main.rs b/apps/gridcraft-web/src/main.rs
--- a/apps/gridcraft-web/src/main.rs
+++ b/apps/gridcraft-web/src/main.rs
@@ -19,6 +19,8 @@
 #[cfg(target_arch = "wasm32")]
 mod host;
 #[cfg(any(target_arch = "wasm32", test))]
+mod native_document;
+#[cfg(any(target_arch = "wasm32", test))]
 mod prefs;
 #[cfg(target_arch = "wasm32")]
 mod web;
diff --git a/apps/gridcraft-web/src/native_document.rs b/apps/gridcraft-web/src/native_document.rs
new file mode 100644
--- /dev/null
+++ b/apps/gridcraft-web/src/native_document.rs
@@ -0,0 +1,114 @@
+//! Whole native documents for same-origin embedding hosts.
+
+use gridcraft_ui_egui::SheetApp;
+use serde_json::json;
+
+pub struct Snapshot {
+    pub bytes: Vec<u8>,
+    pub document: String,
+    pub revision: String,
+}
+
+/// Encode the complete native workbook through the same command as desktop/CLI.
+/// `SheetApp::run` settles pending cell and text-box drafts before file commands.
+/// Export is not a durable save and deliberately leaves the dirty baseline alone.
+pub fn export(app: &mut SheetApp) -> Result<Snapshot, String> {
+    let encoded = app.run("file.saveBytes", json!({"format": "json"}))?;
+    let bytes = encoded
+        .get("base64")
+        .and_then(serde_json::Value::as_str)
+        .and_then(gridcraft_engine::io::base64_decode)
+        .ok_or_else(|| "The engine did not return a native workbook.".to_string())?;
+    validate(&bytes)?;
+    // A pending draft may have advanced the revision during the file command.
+    let doc = app.session.active().ok_or_else(|| "No workbook is open.".to_string())?;
+    Ok(Snapshot { bytes, document: doc.uid.to_string(), revision: doc.revision.to_string() })
+}
+
+/// Decode using the application's engine without changing the open workbook.
+pub fn validate(bytes: &[u8]) -> Result<(), String> {
+    // The file reader also sniffs XLSX irrespective of the supplied extension.
+    // This endpoint promises native JSON, not an arbitrary import.
+    serde_json::from_slice::<serde_json::Value>(bytes).map_err(|e| e.to_string())?;
+    let (_, warnings) = gridcraft_engine::io::open_bytes("snapshot.json", bytes).map_err(|e| e.to_string())?;
+    if !warnings.is_empty() {
+        return Err(warnings.join("\n"));
+    }
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use gridcraft_engine::Session;
+    use gridcraft_ui_egui::{Services, SheetApp};
+    use serde_json::json;
+
+    fn app() -> SheetApp {
+        let mut session = Session::new();
+        session.new_workbook();
+        SheetApp::new(session, Services::default())
+    }
+
+    #[test]
+    fn native_export_commits_the_cell_draft_without_claiming_durable_save() {
+        let mut app = app();
+        app.run("cell.set", json!({"cell":"A1", "input":"21"})).unwrap();
+        app.run("cell.set", json!({"cell":"B1", "input":"=A1*2"})).unwrap();
+        app.run("home.bold", json!({"range":"A1", "on":true})).unwrap();
+        app.run("home.numberFormat", json!({"range":"B1", "code":"0.00"})).unwrap();
+        app.run("home.mergeCells", json!({"range":"A4:C4"})).unwrap();
+        app.run("home.rowHeight", json!({"rows":"5:5", "height":42})).unwrap();
+        app.run("home.insertSheet", json!({"name":"Second"})).unwrap();
+        app.run("cell.set", json!({"cell":"A1", "input":"Second sheet"})).unwrap();
+        app.run("sheet.activate", json!({"sheet":"Sheet1"})).unwrap();
+        app.run("selection.set", json!({"cell":"A1"})).unwrap();
+        let before = app.session.active().unwrap().revision;
+        let document = app.session.active().unwrap().uid.to_string();
+        app.begin_edit(Some("22".into()), false);
+        let snapshot = super::export(&mut app).unwrap();
+        assert_eq!(snapshot.document, document);
+        assert!(app.session.active().unwrap().revision > before);
+        assert_eq!(snapshot.revision, app.session.active().unwrap().revision.to_string());
+        let bytes = snapshot.bytes;
+        assert!(app.editor.is_none());
+        assert!(app.session.active().unwrap().is_dirty());
+        let (wb, _) = gridcraft_engine::io::open_bytes("snapshot.json", &bytes).unwrap();
+        // Formula AST caches and rebuilt style indices are intentionally not persisted.
+        assert_eq!(serde_json::to_value(&wb).unwrap(), serde_json::to_value(app.session.active().unwrap().wb.as_ref()).unwrap());
+        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
+        assert!(json.get("sheets").is_some());
+    }
+
+    #[test]
+    fn native_validation_rejects_invalid_documents() {
+        assert!(super::validate(b"not JSON").is_err());
+        assert!(super::validate(br#"{"sheets":"broken"}"#).is_err());
+    }
+
+    #[test]
+    fn rejected_draft_is_retained_instead_of_exporting_an_older_cell() {
+        let mut app = app();
+        app.begin_edit(Some("=1+".into()), false);
+        assert!(super::export(&mut app).is_err());
+        assert!(app.editor.is_some());
+    }
+
+    #[test]
+    fn native_validation_does_not_accept_an_xlsx_disguised_as_json() {
+        let mut session = Session::new();
+        session.new_workbook();
+        let encoded = session.execute("file.saveBytes", json!({"format":"xlsx"})).unwrap();
+        let bytes = gridcraft_engine::io::base64_decode(encoded["base64"].as_str().unwrap()).unwrap();
+        assert!(super::validate(&bytes).is_err());
+    }
+
+    #[test]
+    fn native_export_has_no_four_mib_checkpoint_limit() {
+        let mut app = app();
+        let values: Vec<Vec<String>> = (0..130).map(|_| vec!["x".repeat(32767)]).collect();
+        app.run("range.setValues", json!({"range":"A1", "values":values})).unwrap();
+        let bytes = super::export(&mut app).unwrap().bytes;
+        assert!(bytes.len() > 4 * 1024 * 1024);
+        super::validate(&bytes).unwrap();
+    }
+}
