diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -930,6 +930,7 @@ version = "0.11.1"
 dependencies = [
  "arboard",
  "chrono",
+ "clipboard-win",
  "cpal",
  "craft-update",
  "deckcraft-engine",
diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
 
 **Stage: alpha** · next: beta, ~20 points and ~120–210 h away
 
-> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: Windows native clipboard between separate processes; previous: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
 > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
 > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: picture and embedded object fills; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
 > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
@@ -137,6 +137,7 @@ app's JSON control channel (`deckcraft --control PORT`).
 
 | Date | What landed |
 |---|---|
+| 2026-10-26 | Windows: copy/cut slides and shapes between independent DeckCraft processes using a versioned registered clipboard format, with embedded media; pasted slides take the matching layout of the destination theme, as Reuse Slides does; keyboard and menu paste prefer native objects while other apps retain HTML and text |
 | 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI `<Icon>`, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once |
 | 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off |
 | 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table |
@@ -294,6 +295,7 @@ app's JSON control channel (`deckcraft --control PORT`).
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-26 | trivial | Windows native object clipboard between separate processes; cross-platform parity score unchanged |
 | 2026-10-25 | trivial | Progress log: Windows shortcut icon survives updates (#1114). Scores and milestone names unchanged |
 | 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged |
 | 2026-10-24 | trivial | Progress log: measurement units in centimetres (#1326). Scores unchanged |
diff --git a/apps/deckcraft-cli/tests/it/cli.rs b/apps/deckcraft-cli/tests/it/cli.rs
--- a/apps/deckcraft-cli/tests/it/cli.rs
+++ b/apps/deckcraft-cli/tests/it/cli.rs
@@ -104,7 +104,9 @@ Acts on the selected text or shapes.
 Run it with: deckcraft-cli run --cmd 'format.size={…}'
 "
     );
-    assert!(stdout(&cli(&["describe", "edit.copy"])).contains("Answers with {text, html?}.\n\nRun it with: deckcraft-cli run --cmd edit.copy\n"));
+    assert!(
+        stdout(&cli(&["describe", "edit.copy"])).contains("Answers with {text, html?, copied}.\n\nRun it with: deckcraft-cli run --cmd edit.copy\n")
+    );
     assert!(stdout(&cli(&["describe", "text.set"])).contains("Note: replaces the whole text, keeping the first run's formatting\n"));
     assert_eq!(
         stdout(&cli(&["describe", "shape.setDefault"])),
diff --git a/apps/deckcraft/Cargo.toml b/apps/deckcraft/Cargo.toml
--- a/apps/deckcraft/Cargo.toml
+++ b/apps/deckcraft/Cargo.toml
@@ -38,5 +38,9 @@ cpal = "0.16"
 # our workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
 fmv-macos-events = "=0.1.0"
 
+[target.'cfg(target_os = "windows")'.dependencies]
+# Safe access to registered formats; already a transitive dependency of arboard.
+clipboard-win = "5.4.1"
+
 [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
 winit = { version = "0.30", default-features = false, features = ["x11"] }
diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs
--- a/apps/deckcraft/src/main.rs
+++ b/apps/deckcraft/src/main.rs
@@ -127,6 +127,10 @@ fn services() -> Services {
         clipboard_image: Some(Box::new(pasteboard::image_png)),
         clipboard_html: Some(Box::new(pasteboard::html)),
         clipboard_set_html: Some(Box::new(pasteboard::set_html)),
+        #[cfg(target_os = "windows")]
+        clipboard_native: Some(Box::new(pasteboard::native)),
+        #[cfg(target_os = "windows")]
+        clipboard_set_native: Some(Box::new(pasteboard::set_native)),
         // Media is mixed and clocked by the output device; `DECKCRAFT_NO_AUDIO` plays silently.
         audio_out: std::env::var_os("DECKCRAFT_NO_AUDIO")
             .is_none()
diff --git a/apps/deckcraft/src/pasteboard.rs b/apps/deckcraft/src/pasteboard.rs
--- a/apps/deckcraft/src/pasteboard.rs
+++ b/apps/deckcraft/src/pasteboard.rs
@@ -4,6 +4,44 @@
 //! words. A shape with no text is copied as an image by the UI; this module only reads a picture
 //! back (Keynote's TIFF) as PNG bytes.
 
+#[cfg(target_os = "windows")]
+const NATIVE_FORMAT: &str = "DeckCraft.Clipboard.v1";
+
+/// Read under one clipboard lock, with a length prefix because Windows can pad allocations.
+#[cfg(target_os = "windows")]
+pub fn native() -> Option<Vec<u8>> {
+    let format = clipboard_win::register_format(NATIVE_FORMAT)?.get();
+    let _lock = clipboard_win::Clipboard::new_attempts(10).ok()?;
+    let size = clipboard_win::size(format)?.get();
+    if size > deckcraft_engine::clipboard::MAX_BYTES + 4 {
+        return None;
+    }
+    let bytes: Vec<u8> = clipboard_win::get(clipboard_win::formats::RawData(format)).ok()?;
+    let len = u32::from_le_bytes(bytes.get(..4)?.try_into().ok()?) as usize;
+    bytes.get(4..4usize.checked_add(len)?).map(<[u8]>::to_vec)
+}
+
+/// All formats are offered atomically; do not let egui's later CopyText clear native objects.
+#[cfg(target_os = "windows")]
+pub fn set_native(bytes: &[u8], html: &str, text: &str) -> bool {
+    fn write(bytes: &[u8], html: &str, text: &str) -> Option<()> {
+        if bytes.len() > deckcraft_engine::clipboard::MAX_BYTES {
+            return None;
+        }
+        let format = clipboard_win::register_format(NATIVE_FORMAT)?.get();
+        let mut framed = (u32::try_from(bytes.len()).ok()?).to_le_bytes().to_vec();
+        framed.extend_from_slice(bytes);
+        let _lock = clipboard_win::Clipboard::new_attempts(10).ok()?;
+        clipboard_win::set(clipboard_win::formats::Unicode, text).ok()?;
+        if !html.is_empty() {
+            clipboard_win::set(clipboard_win::formats::Html::new()?, html).ok()?;
+        }
+        clipboard_win::raw::set_without_clear(format, &framed).ok()?;
+        Some(())
+    }
+    write(bytes, html, text).is_some()
+}
+
 /// HTML currently on the pasteboard, when there is some.
 pub fn html() -> Option<String> {
     let mut cb = arboard::Clipboard::new().ok()?;
@@ -34,6 +72,27 @@ pub fn image_png() -> Option<Vec<u8>> {
 mod tests {
     use std::borrow::Cow;
 
+    /// This touches the real OS clipboard: run explicitly, serially, on a desktop session.
+    #[cfg(target_os = "windows")]
+    #[test]
+    #[ignore = "writes the real Windows clipboard"]
+    #[allow(clippy::unwrap_used)]
+    fn native_clipboard_retains_html_and_plain_text_and_clears_on_external_copy() {
+        let _restore = Restore(arboard::Clipboard::new().ok().and_then(|mut cb| cb.get_text().ok()));
+        let bytes = b"native objects and media\0with binary bytes";
+        assert!(super::set_native(bytes, "<b>DeckCraft</b>", "DeckCraft"));
+        assert_eq!(super::native().as_deref(), Some(bytes.as_slice()));
+        assert!(super::html().unwrap().contains("<b>DeckCraft</b>"));
+        let mut cb = arboard::Clipboard::new().unwrap();
+        assert_eq!(cb.get_text().unwrap(), "DeckCraft");
+        cb.set_text("another app").unwrap();
+        drop(cb);
+        assert!(super::native().is_none(), "an external copy must retire our objects");
+        assert!(super::set_native(bytes, "", "\u{2060}"));
+        assert_eq!(super::native().as_deref(), Some(bytes.as_slice()));
+        assert_eq!(arboard::Clipboard::new().unwrap().get_text().unwrap(), "\u{2060}");
+    }
+
     /// Puts HTML and a 1×1 picture on the macOS pasteboard and reads them back.
     /// Restores whatever plain text was there. Other platforms return immediately.
     #[test]
diff --git a/crates/engine/src/clipboard.rs b/crates/engine/src/clipboard.rs
new file mode 100644
--- /dev/null
+++ b/crates/engine/src/clipboard.rs
@@ -0,0 +1,226 @@
+//! Versioned, self-contained system clipboard payload. MediaItem's regular JSON deliberately
+//! omits bytes; carry those explicitly, without temporary files or paths into the source app.
+
+use std::sync::Arc;
+
+use serde::{Deserialize, Serialize};
+
+use crate::Clipboard;
+use crate::cmd::{base64_decode, base64_encode};
+
+/// Bound clipboard allocations independently of the much larger document-file budget.
+pub const MAX_BYTES: usize = 64 << 20;
+
+#[derive(Serialize, Deserialize)]
+struct Payload {
+    version: u32,
+    clipboard: Clipboard,
+    media: Vec<String>,
+}
+
+impl Clipboard {
+    /// Adopt a system offer, keeping local state when it is the same selection.
+    pub fn adopt_system_bytes(&mut self, bytes: &[u8]) -> bool {
+        if self.system_bytes.is_none() {
+            self.system_bytes = self.to_system_bytes();
+        }
+        if self.system_bytes.as_deref() == Some(bytes) {
+            return true;
+        }
+        let Some(mut clipboard) = Self::from_system_bytes(bytes) else { return false };
+        clipboard.system_bytes = Some(bytes.to_vec());
+        *self = clipboard;
+        true
+    }
+
+    /// Encode only this selection and its dependencies. Paste counters are process-local.
+    pub fn to_system_bytes(&self) -> Option<Vec<u8>> {
+        if self.shapes.is_empty() && self.slides.is_empty() && self.text.is_none() {
+            return None;
+        }
+        let size = self.media.iter().try_fold(0usize, |n, m| n.checked_add(m.data.len()))?;
+        if size > MAX_BYTES / 2 {
+            return None;
+        }
+        let payload = Payload { version: 1, clipboard: self.clone(), media: self.media.iter().map(|m| base64_encode(&m.data)).collect() };
+        let bytes = serde_json::to_vec(&payload).ok()?;
+        (bytes.len() <= MAX_BYTES).then_some(bytes)
+    }
+
+    /// Unknown versions, malformed JSON/base64 and incomplete media fall back to normal paste.
+    pub fn from_system_bytes(bytes: &[u8]) -> Option<Self> {
+        if bytes.len() > MAX_BYTES {
+            return None;
+        }
+        let mut payload: Payload = serde_json::from_slice(bytes).ok()?;
+        if payload.version != 1 || payload.media.len() != payload.clipboard.media.len() {
+            return None;
+        }
+        for (item, data) in payload.clipboard.media.iter_mut().zip(payload.media) {
+            item.data = Arc::new(base64_decode(&data)?);
+        }
+        if payload.clipboard.shapes.is_empty() && payload.clipboard.slides.is_empty() && payload.clipboard.text.is_none() {
+            return None;
+        }
+        payload.clipboard.foreign = true;
+        payload.clipboard.pastes = 0;
+        Some(payload.clipboard)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    #![allow(clippy::unwrap_used, clippy::expect_used)]
+
+    use super::*;
+    use crate::Session;
+    use serde_json::json;
+
+    #[test]
+    fn system_clipboard_round_trip_with_media_and_collision() {
+        let mut src = Session::with_new();
+        // A contributor-original one-pixel PNG, generated by the image crate.
+        let img = image::RgbaImage::from_pixel(1, 1, image::Rgba([60, 120, 180, 255]));
+        let mut png = std::io::Cursor::new(Vec::new());
+        img.write_to(&mut png, image::ImageFormat::Png).unwrap();
+        let png = png.into_inner();
+        src.execute("insert.picture", &json!({"name": "pixel.png", "data": base64_encode(&png)})).unwrap();
+        src.execute("edit.copy", &json!({})).unwrap();
+        let bytes = src.clipboard.to_system_bytes().unwrap();
+        let mut dst = Session::with_new();
+        let id = src.clipboard.media[0].id;
+        dst.edit(|doc, _| {
+            doc.media.push(crate::model::MediaItem { id, data: Arc::new(vec![9]), ..Default::default() });
+            doc.next_id = doc.next_id.max(id.0);
+            Ok(json!({}))
+        })
+        .unwrap();
+        dst.execute("edit.paste", &json!({"clipboard": base64_encode(&bytes)})).unwrap();
+        let st = dst.active().unwrap();
+        assert!(st.doc.media.iter().any(|m| *m.data == png && m.id != id));
+        assert_eq!(st.doc.media(id).unwrap().data.as_ref(), &[9]);
+        let pasted = st.selection.shapes.clone();
+        assert_eq!(pasted.len(), 1);
+        dst.execute("edit.undo", &json!({})).unwrap();
+        assert!(!dst.active().unwrap().doc.slides[0].shapes.iter().any(|s| pasted.contains(&s.id)));
+        dst.execute("edit.redo", &json!({})).unwrap();
+        assert!(dst.active().unwrap().doc.slides[0].shapes.iter().any(|s| pasted.contains(&s.id)));
+    }
+
+    #[test]
+    fn system_clipboard_colliding_shape_ids_keep_position_including_paste_as_picture() {
+        for picture in [false, true] {
+            for foreign in [false, true] {
+                let mut src = Session::with_new();
+                src.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 120, 200, 80]})).unwrap();
+                src.execute("edit.copy", &json!({})).unwrap();
+                let mut dst = Session::with_new();
+                dst.execute("shape.insert", &json!({"preset": "rect", "rect": [300, 320, 40, 40]})).unwrap();
+                let source_id = src.clipboard.shapes[0].id;
+                assert!(dst.doc().unwrap().shape(source_id).is_some());
+                let bytes = src.clipboard.to_system_bytes().unwrap();
+                dst.clipboard = if foreign { Clipboard::from_system_bytes(&bytes).unwrap() } else { src.clipboard.clone() };
+                let off = if foreign { 0.0 } else { dst.prefs.duplicate_offset };
+                if picture {
+                    dst.execute("edit.pasteSpecial", &json!({"as": "picture"})).unwrap();
+                } else {
+                    let params = if foreign { json!({"clipboard": base64_encode(&bytes)}) } else { json!({}) };
+                    dst.execute("edit.paste", &params).unwrap();
+                }
+                let st = dst.doc().unwrap();
+                let pasted = st.shape(st.selection.shapes[0]).unwrap();
+                assert_ne!(pasted.id, source_id);
+                let x = pasted.xfrm.unwrap();
+                // Picture rendering includes an 8 pt margin around the original shape.
+                let margin = if picture { 8.0 } else { 0.0 };
+                assert!((x.x - (100.0 - margin + off)).abs() < 0.01, "picture={picture}, foreign={foreign}: {x:?}");
+                assert!((x.y - (120.0 - margin + off)).abs() < 0.01, "picture={picture}, foreign={foreign}: {x:?}");
+            }
+        }
+    }
+
+    #[test]
+    fn system_clipboard_slides_reuse_destination_layout_without_importing_masters() {
+        let mut src = Session::with_new();
+        src.execute("edit.copy", &json!({"scope": "slides"})).unwrap();
+        let bytes = src.clipboard.to_system_bytes().unwrap();
+        let payload: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
+        assert!(payload["clipboard"].get("masters").is_none());
+        let (name, kind) = src.clipboard.slide_layouts[0].clone();
+        let mut dst = Session::with_new();
+        // Put a name match after a kind match, with a different id and kind from the source.
+        let target = dst
+            .edit(|doc, _| {
+                let id = deckcraft_model::LayoutId(doc.alloc_id().unwrap());
+                let master = Arc::make_mut(&mut doc.masters[0]);
+                for layout in &mut master.layouts {
+                    layout.name = "Different".into();
+                    layout.kind = kind;
+                }
+                let mut layout = master.layouts[0].clone();
+                layout.id = id;
+                layout.name = name.to_ascii_uppercase();
+                layout.kind = deckcraft_model::LayoutType::Custom;
+                master.layouts.push(layout);
+                Ok(json!(id))
+            })
+            .unwrap();
+        let target: deckcraft_model::LayoutId = serde_json::from_value(target).unwrap();
+        let masters = dst.active().unwrap().doc.masters.len();
+        for count in 2..=3 {
+            dst.execute("edit.paste", &json!({"clipboard": base64_encode(&bytes), "text": "unrelated fallback"})).unwrap();
+            let st = dst.active().unwrap();
+            assert_eq!(st.doc.slides.len(), count);
+            assert_eq!(st.doc.masters.len(), masters);
+            assert_eq!(st.doc.slides[count - 1].layout, target);
+            assert_ne!(st.doc.slides[0].id, st.doc.slides[count - 1].id);
+        }
+    }
+
+    #[test]
+    fn system_clipboard_rejects_unknown_and_incomplete_payloads() {
+        assert!(Clipboard::from_system_bytes(b"not json").is_none());
+        let mut s = Session::with_new();
+        s.execute("edit.copy", &json!({"scope": "slides"})).unwrap();
+        let bytes = s.clipboard.to_system_bytes().unwrap();
+        let mut v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
+        v["version"] = json!(2);
+        assert!(Clipboard::from_system_bytes(&serde_json::to_vec(&v).unwrap()).is_none());
+        v["version"] = json!(1);
+        v["media"] = json!(["!!!"]);
+        assert!(Clipboard::from_system_bytes(&serde_json::to_vec(&v).unwrap()).is_none());
+        assert!(Clipboard::from_system_bytes(&vec![0; MAX_BYTES + 1]).is_none());
+    }
+}
+
+#[cfg(test)]
+mod repeated_paste_tests {
+    #![allow(clippy::unwrap_used, clippy::expect_used)]
+    use super::*;
+    use crate::Session;
+    use serde_json::json;
+
+    #[test]
+    fn repeated_foreign_offer_offsets_after_first_paste_and_new_offer_resets() {
+        let mut src = Session::with_new();
+        src.execute("shape.insert", &json!({"preset":"rect", "rect":[100,120,200,80]})).unwrap();
+        src.execute("edit.copy", &json!({})).unwrap();
+        let bytes = src.clipboard.to_system_bytes().unwrap();
+        let mut dst = Session::with_new();
+        for (native, count) in [(true, 0), (true, 1), (false, 2)] {
+            let params = if native { json!({"clipboard":base64_encode(&bytes)}) } else { json!({}) };
+            dst.execute("edit.paste", &params).unwrap();
+            let doc = dst.doc().unwrap();
+            let x = doc.shape(doc.selection.shapes[0]).unwrap().xfrm.unwrap();
+            let offset = dst.prefs.duplicate_offset * count as f64;
+            assert_eq!((x.x, x.y), (100.0 + offset, 120.0 + offset));
+        }
+        src.execute("shape.insert", &json!({"preset":"rect", "rect":[200,220,200,80]})).unwrap();
+        src.execute("edit.copy", &json!({})).unwrap();
+        let bytes = src.clipboard.to_system_bytes().unwrap();
+        dst.execute("edit.paste", &json!({"clipboard":base64_encode(&bytes)})).unwrap();
+        let doc = dst.doc().unwrap();
+        let x = doc.shape(doc.selection.shapes[0]).unwrap().xfrm.unwrap();
+        assert_eq!((x.x, x.y), (200.0, 220.0));
+    }
+}
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -17,14 +17,14 @@ pub fn specs() -> Vec<CommandSpec> {
     vec![
         cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
         cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{}", can_redo, redo),
-        cmd!("edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{scope?: slides} → {text, html?}", has_doc, cut),
-        cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides} → {text, html?}", has_doc, copy),
+        cmd!("edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{scope?: slides} → {text, html?, copied}", has_doc, cut),
+        cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides} → {text, html?, copied}", has_doc, copy),
         cmd!(
             "edit.paste",
             "Paste",
             ["Home", "Clipboard"],
             Some("Cmd+V"),
-            "{text?: plain text to paste, html?: rich text from another app, scope?: slides}",
+            "{text?: plain text to paste, html?: rich text from another app, clipboard?: base64 native selection, scope?: slides}",
             has_doc,
             paste
         ),
@@ -97,9 +97,10 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
         for sl in &slides {
             mids.extend(crate::media_refs::slide_media(sl));
         }
+        let slide_layouts = slides.iter().map(|sl| st.doc.layout(sl.layout).map(|(_, l)| (l.name.clone(), l.kind)).unwrap_or_default()).collect();
         let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect();
         let plain = slides.iter().map(|x| x.title()).collect::<Vec<_>>().join("\n");
-        s.clipboard = crate::Clipboard { slides, media, plain: plain.clone(), ..Default::default() };
+        s.clipboard = crate::Clipboard { slides, slide_layouts, media, plain: plain.clone(), ..Default::default() };
         return Ok(plain);
     }
     // Selected table cells copy as their text: a tab between cells and a paragraph per row.
@@ -137,7 +138,7 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
     let caret = copying_caret(s);
     let t = copy_inner(s, p)?;
     let html = if caret { String::new() } else { copied_html(s) };
-    Ok(json!({"text": t, "html": html}))
+    Ok(json!({"text": t, "html": html, "copied": !caret}))
 }
 
 /// HTML for what was just copied, when it is text (a selection, or the text on the copied shapes).
@@ -162,11 +163,11 @@ fn cut(s: &mut Session, p: &Value) -> Result<Value> {
     let t = copy_inner(s, p)?;
     if caret {
         // Nothing is selected, so there is nothing to cut; `delete` would remove the next character.
-        return Ok(json!({"text": t, "html": ""}));
+        return Ok(json!({"text": t, "html": "", "copied": false}));
     }
     let html = copied_html(s);
     delete(s, p)?;
-    Ok(json!({"text": t, "html": html}))
+    Ok(json!({"text": t, "html": html, "copied": true}))
 }
 
 /// Give pasted shapes fresh ids (groups included) and return the old-to-new map. Connector ends
@@ -264,9 +265,14 @@ fn bring_media(cmd: &str, doc: &mut deckcraft_model::Presentation, media: &[deck
 }
 
 fn paste(s: &mut Session, p: &Value) -> Result<Value> {
+    let has_native = str_param(p, "clipboard")
+        .filter(|data| data.len() <= crate::clipboard::MAX_BYTES * 4 / 3 + 4)
+        .and_then(super::base64_decode)
+        .is_some_and(|bytes| s.clipboard.adopt_system_bytes(&bytes));
     // External plain text (from the system clipboard) wins when given and differs from ours.
     // HTML from Pages or Keynote keeps bold, italic and underline when it says the same words.
-    if let Some(t) = str_param(p, "text")
+    if !has_native
+        && let Some(t) = str_param(p, "text")
         && t != s.clipboard.plain
     {
         if let Some(html) = str_param(p, "html")
@@ -297,7 +303,10 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
                 let map = reid("edit.paste", doc, &mut sl.shapes)?;
                 remap_animations(&mut sl.animations, &map);
                 crate::media_refs::visit_mut::slide(&mut sl, &mut remap_media(&media));
-                if doc.layout(sl.layout).is_none() {
+                if clip.foreign {
+                    let (name, kind) = clip.slide_layouts.get(k).cloned().unwrap_or_default();
+                    sl.layout = super::reuse::matching_layout(doc, &name, kind);
+                } else if doc.layout(sl.layout).is_none() {
                     sl.layout = doc.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();
                 }
                 new_ids.push(sl.id);
@@ -314,8 +323,14 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
         s.clipboard.pastes += 1;
         let off = s.prefs.duplicate_offset * s.clipboard.pastes as f64;
         // Pasting on another slide keeps the position; on the same slide it offsets.
-        let same_slide = s.doc().ok().is_some_and(|d| clip.shapes.iter().any(|c| d.shape(c.id).is_some()));
-        let off = if same_slide { off } else { 0.0 };
+        let same_slide = !clip.foreign && s.doc().ok().is_some_and(|d| clip.shapes.iter().any(|c| d.shape(c.id).is_some()));
+        let off = if clip.foreign {
+            s.prefs.duplicate_offset * clip.pastes as f64
+        } else if same_slide {
+            off
+        } else {
+            0.0
+        };
         return s.edit(|doc, sel| {
             let media = bring_media("edit.paste", doc, &clip.media)?;
             let mut shapes = clip.shapes.clone();
@@ -497,7 +512,7 @@ fn paste_picture(s: &mut Session) -> Result<Value> {
     let img = deckcraft_render::render_shape(&doc, &slide, gid, SCALE).ok_or_else(|| bad("edit.pasteSpecial", "the shapes could not be drawn"))?;
     // render_shape draws the bounds plus an 8 pt margin (room for shadows and outlines).
     let (w, h) = (img.width as f64 / SCALE, img.height as f64 / SCALE);
-    let same_slide = clip.shapes.iter().any(|c| st.shape(c.id).is_some());
+    let same_slide = !clip.foreign && clip.shapes.iter().any(|c| st.shape(c.id).is_some());
     let off = if same_slide { s.prefs.duplicate_offset } else { 0.0 };
     let png = img.to_png();
     s.execute("insert.picture", &json!({"data": base64_encode(&png), "name": "Picture.png", "rect": [b.x0 - 8.0 + off, b.y0 - 8.0 + off, w, h]}))
diff --git a/crates/engine/src/cmd/reuse.rs b/crates/engine/src/cmd/reuse.rs
--- a/crates/engine/src/cmd/reuse.rs
+++ b/crates/engine/src/cmd/reuse.rs
@@ -142,7 +142,7 @@ fn renumber(cmd: &str, doc: &mut Presentation, shapes: &mut [deckcraft_model::Sh
 }
 
 /// The destination layout a reused slide moves to: same name, else same kind, else the first.
-fn matching_layout(doc: &Presentation, name: &str, kind: deckcraft_model::LayoutType) -> LayoutId {
+pub(crate) fn matching_layout(doc: &Presentation, name: &str, kind: deckcraft_model::LayoutType) -> LayoutId {
     let all = || doc.masters.iter().flat_map(|m| m.layouts.iter());
     all()
         .find(|l| !name.is_empty() && l.name.eq_ignore_ascii_case(name))
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -7,6 +7,7 @@
 #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
 #![forbid(unsafe_code)]
 
+pub mod clipboard;
 pub mod cmd;
 pub mod compare;
 pub mod connect;
@@ -454,7 +455,7 @@ pub struct MediaStatus {
 }
 
 /// Clipboard contents copied within the app.
-#[derive(Clone, Debug, Default)]
+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
 pub struct Clipboard {
     pub shapes: Vec<Shape>,
     pub slides: Vec<Slide>,
@@ -465,8 +466,18 @@ pub struct Clipboard {
     /// The copied shapes' theme colours (colour map applied) and fonts, for Paste Special ›
     /// Keep Source Formatting.
     pub source_theme: Option<(deckcraft_color::ColorScheme, deckcraft_model::theme::FontScheme)>,
+    /// Source layout name and kind for each copied slide, for destination-theme matching.
+    #[serde(default)]
+    pub slide_layouts: Vec<(String, deckcraft_model::LayoutType)>,
+    /// Slides from another process use the destination theme and layouts.
+    #[serde(skip)]
+    pub foreign: bool,
     /// How many times the shapes were pasted (each paste offsets further).
+    #[serde(skip)]
     pub pastes: u32,
+    /// Cached system representation used to recognize repeated offers.
+    #[serde(skip)]
+    pub system_bytes: Option<Vec<u8>>,
 }
 
 pub struct Session {
diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs
--- a/crates/ui-egui/src/app.rs
+++ b/crates/ui-egui/src/app.rs
@@ -344,7 +344,7 @@ impl SlideApp {
             ctx.open_url(egui::OpenUrl::new_tab(url));
         }
         if let Some(t) = self.pending_copy.take() {
-            ctx.copy_text(t);
+            self.offer_system_clipboard(&ctx, t);
         }
         self.drain_requests();
         doc_window::show(self, &ctx);
diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs
--- a/crates/ui-egui/src/app/clipboard.rs
+++ b/crates/ui-egui/src/app/clipboard.rs
@@ -14,19 +14,42 @@ pub(crate) fn system_clipboard_text(t: &str) -> String {
 impl SlideApp {
     /// Offer the copy or cut on the system clipboard.
     ///
-    /// Slides go out as plain text. Text goes out as HTML plus the same plain words, so a paste
-    /// back into DeckCraft still uses the internal clipboard. A textless shape leaves the
+    /// Native hosts offer objects alongside HTML and plain text. A textless shape leaves the
     /// invisible [`OBJECTS_MARKER`] rather than a picture: with only an image on the clipboard
     /// egui-winit drops Ctrl+V, and the shortcut could no longer paste the shape here (#182).
     pub(super) fn publish_clipboard(&mut self, ctx: &egui::Context, cut: bool) {
         let cmd = if cut { "edit.cut" } else { "edit.copy" };
         let Ok(v) = self.session.execute(cmd, &json!({})) else { return };
-        let text = v.get("text").and_then(Value::as_str).unwrap_or("").to_string();
-        let html = v.get("html").and_then(Value::as_str).unwrap_or("").to_string();
-        if self.session.clipboard.slides.is_empty() && !html.is_empty() && self.services.clipboard_set_html.as_mut().is_some_and(|f| f(&html, &text))
-        {
+        if let Some(offer) = self.clipboard_offer(&v) {
+            self.offer_system_clipboard(ctx, offer);
+        }
+    }
+    pub(super) fn clipboard_offer(&self, result: &Value) -> Option<(String, String, Option<Vec<u8>>)> {
+        if result.get("copied").and_then(Value::as_bool) == Some(false) {
+            return None;
+        }
+        Some((
+            result.get("text").and_then(Value::as_str).unwrap_or("").to_string(),
+            result.get("html").and_then(Value::as_str).unwrap_or("").to_string(),
+            self.services.clipboard_set_native.as_ref().and_then(|_| self.session.clipboard.to_system_bytes()),
+        ))
+    }
+
+    pub(super) fn offer_system_clipboard(&mut self, ctx: &egui::Context, (text, html, native): (String, String, Option<Vec<u8>>)) {
+        let plain = system_clipboard_text(&text);
+        if native.as_ref().is_some_and(|bytes| self.services.clipboard_set_native.as_mut().is_some_and(|f| f(bytes, &html, &plain))) {
             return;
         }
-        ctx.copy_text(system_clipboard_text(&text));
+        if !html.is_empty() && self.services.clipboard_set_html.as_mut().is_some_and(|f| f(&html, &text)) {
+            return;
+        }
+        ctx.copy_text(plain);
+    }
+
+    /// Prefer objects over their text alternative. An unchanged offer keeps the local paste
+    /// counter and clipboard, including when switching tabs inside the source window.
+    pub(super) fn import_system_clipboard(&mut self) -> bool {
+        let Some(bytes) = self.services.clipboard_native.as_mut().and_then(|f| f()) else { return false };
+        self.session.clipboard.adopt_system_bytes(&bytes)
     }
 }
diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs
--- a/crates/ui-egui/src/app/commands.rs
+++ b/crates/ui-egui/src/app/commands.rs
@@ -16,6 +16,9 @@ impl SlideApp {
     /// Run a command by id (UI commands here, everything else in the engine). Errors show in the
     /// status bar and are returned.
     pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
+        if id == "edit.paste" && params.get("text").is_none() && params.get("clipboard").is_none() {
+            self.import_system_clipboard();
+        }
         let r = if UI_COMMANDS.iter().any(|c| c.0 == id) {
             self.run_ui(id, &params)
         } else {
@@ -25,9 +28,7 @@ impl SlideApp {
             // Copy and Cut from the ribbon, menus, the slide's context menu or the control channel
             // put the text on the system clipboard, as Ctrl+C and Ctrl+X do (#431).
             Ok(v) if matches!(id, "edit.copy" | "edit.cut") => {
-                if let Some(t) = v.get("text").and_then(Value::as_str) {
-                    self.pending_copy = Some(t.to_string());
-                }
+                self.pending_copy = self.clipboard_offer(v);
             }
             Ok(_) => {}
             Err(e) => self.set_status(e.clone()),
diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs
--- a/crates/ui-egui/src/app/keys.rs
+++ b/crates/ui-egui/src/app/keys.rs
@@ -146,6 +146,7 @@ impl SlideApp {
                 egui::Event::Copy => self.publish_clipboard(ctx, false),
                 egui::Event::Cut => self.publish_clipboard(ctx, true),
                 egui::Event::Paste(text) => {
+                    let native = self.import_system_clipboard();
                     // Our own marker for objects without text: paste what the session copied.
                     let text = if text == OBJECTS_MARKER { String::new() } else { text };
                     // A picture (TIFF from Keynote, a screenshot) lands when the paste has no text.
@@ -153,15 +154,19 @@ impl SlideApp {
                     let html = self.services.clipboard_html.as_mut().and_then(|f| f()).filter(|h| !h.is_empty());
                     // egui 0.37 sends `PasteImage` directly; the services fallback stays for
                     // integrations that still send an empty `Paste`. Never insert twice.
-                    let img = if text.is_empty() && !pasted_image_event { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None };
+                    let img = if !native && text.is_empty() && !pasted_image_event {
+                        self.services.clipboard_image.as_mut().and_then(|f| f())
+                    } else {
+                        None
+                    };
                     let revision = self.session.active().map(|d| d.revision);
                     let r = match img {
                         Some(png) => self
                             .session
                             .execute("insert.picture", &json!({"name": "Pasted image.png", "data": deckcraft_engine::cmd::base64_encode(&png)})),
                         None => {
-                            let mut params = json!({"text": text});
-                            if let Some(h) = html {
+                            let mut params = if native { json!({}) } else { json!({"text": text}) };
+                            if let Some(h) = html.filter(|_| !native) {
                                 params["html"] = Value::String(h);
                             }
                             self.session.execute("edit.paste", &params)
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -281,7 +281,7 @@ pub struct SlideApp {
     #[cfg(not(target_arch = "wasm32"))]
     updates_rt: updates_ui::Runtime,
     /// Text for the system clipboard from a Copy or Cut command, handed over on the next frame.
-    pending_copy: Option<String>,
+    pending_copy: Option<(String, String, Option<Vec<u8>>)>,
     /// Bounded screenshot completion slots for the control channel (egui 0.37 callbacks).
     capture: capture::Capture,
     styled: bool,
diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs
--- a/crates/ui-egui/src/services.rs
+++ b/crates/ui-egui/src/services.rs
@@ -54,6 +54,10 @@ pub struct Services {
     pub clipboard_html: Option<Box<dyn FnMut() -> Option<String>>>,
     /// Put HTML and its plain-text alternative on the system clipboard. `true` when it landed.
     pub clipboard_set_html: Option<Box<dyn FnMut(&str, &str) -> bool>>,
+    /// Native DeckCraft objects from the system clipboard (versioned engine payload).
+    pub clipboard_native: Option<Box<dyn FnMut() -> Option<Vec<u8>>>>,
+    /// Publish native objects, HTML and plain text together. `true` prevents egui overwriting it.
+    pub clipboard_set_native: Option<Box<dyn FnMut(&[u8], &str, &str) -> bool>>,
     /// Audio output for media playback (cpal on desktop); without one media plays silently.
     pub audio_out: Option<Box<dyn deckcraft_media::AudioOut>>,
 }
diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs
--- a/crates/ui-egui/src/tests.rs
+++ b/crates/ui-egui/src/tests.rs
@@ -2339,3 +2339,72 @@ fn overdue_visible_logic_does_not_reanchor_a_later_hidden_show() {
     let _ = ctx.run_logic(&raw_at(12.0, true, false), |ctx| app.tick_hidden_show(ctx, visible_anchor + std::time::Duration::from_secs(3)));
     assert_eq!(app.show.as_ref().unwrap().state.slide, 1, "time from the full visible frame is preserved through the logic-only pass");
 }
+
+/// Independent sessions exchange objects through the host, by keyboard and menu, even when
+/// their plain-text alternatives are identical. Source closure does not invalidate bytes.
+#[test]
+fn system_clipboard_between_sessions_keyboard_and_menu() {
+    use std::cell::RefCell;
+    use std::rc::Rc;
+    for slides in [false, true] {
+        let shared = Rc::new(RefCell::new(None::<Vec<u8>>));
+        let record = shared.clone();
+        let services = Services {
+            clipboard_set_native: Some(Box::new(move |bytes, _, text| {
+                assert!(!text.is_empty(), "textless objects still produce a paste event");
+                *record.borrow_mut() = Some(bytes.to_vec());
+                true
+            })),
+            ..Default::default()
+        };
+        let mut source = step_app(services);
+        source.state_mut().run("shape.insert", json!({"preset": "star5", "rect": [80, 80, 120, 90]})).unwrap();
+        if slides {
+            source.state_mut().run("edit.cut", json!({"scope": "slides"})).unwrap();
+            source.run_steps(1);
+        } else {
+            source.event(egui::Event::Copy);
+            source.run_steps(1);
+        }
+        assert!(shared.borrow().is_some());
+        assert!(!source.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(_))));
+        drop(source);
+        let read = shared.clone();
+        let services = Services { clipboard_native: Some(Box::new(move || read.borrow().clone())), ..Default::default() };
+        let mut dest = step_app(services);
+        let before = dest.state().session.active().unwrap().doc.slides[0].shapes.len();
+        if slides {
+            dest.state_mut().run("edit.paste", json!({"scope": "slides"})).unwrap();
+        } else {
+            dest.event(egui::Event::Paste(OBJECTS_MARKER.into()));
+            dest.run_steps(1);
+        }
+        let st = dest.state().session.active().unwrap();
+        if slides {
+            assert_eq!(st.doc.slides.len(), 2);
+            assert_eq!(st.doc.slides[1].shapes.len(), before + 1);
+        } else {
+            assert_eq!(st.doc.slides[0].shapes.len(), before + 1);
+            dest.event(egui::Event::Paste(OBJECTS_MARKER.into()));
+            dest.run_steps(1);
+            assert_eq!(dest.state().session.clipboard.pastes, 2, "same offer must not reset the offset counter");
+        }
+        dest.state_mut().run("edit.undo", json!({})).unwrap();
+        dest.state_mut().run("edit.redo", json!({})).unwrap();
+    }
+}
+
+#[test]
+fn system_clipboard_invalid_native_preserves_html_fallback() {
+    let services = Services {
+        clipboard_native: Some(Box::new(|| Some(b"invalid native format".to_vec()))),
+        clipboard_html: Some(Box::new(|| Some("<p><b>External</b></p>".into()))),
+        ..Default::default()
+    };
+    let mut dest = step_app(services);
+    dest.event(egui::Event::Paste("External".into()));
+    dest.run_steps(1);
+    let st = dest.state().session.active().unwrap();
+    let text = st.doc.slides[0].shapes.iter().filter_map(|s| s.text.as_ref()).find(|t| t.text() == "External").unwrap();
+    assert_eq!(text.paragraphs[0].runs[0].props.bold, Some(true));
+}
diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md
--- a/docs/parity-checklist.md
+++ b/docs/parity-checklist.md
@@ -78,7 +78,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
 | Slides | Slide layouts (11 standard) | P0 | D |
 | Slides | Reset slide | P1 | D |
 | Slides | Slides from outline | P2 | D |
-| Slides | Copy/paste slides between decks | P1 | P |
+| Slides | Copy/paste slides between decks (including separate Windows processes; native system format not yet offered on other hosts) | P1 | P |
 | Slides | Zoom (summary, section and slide zoom) | P2 | P |
 | Views | Normal view (thumbnails, slide, notes) | P0 | D |
 | Views | Outline view | P1 | D |
@@ -233,3 +233,5 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
 | Platforms (✱) | macOS, Windows, Linux builds | P0 | P |
 | Platforms (✱) | Web (WASM) build | P1 | D |
 | Platforms (✱) | BSD build | P2 | P |
+
+2026-10-26: Windows native clipboard transfer now covers slides, shapes and media across processes; pasted slides take the destination layout with the same name or type. The between-decks row stays P pending native clipboard adapters on other hosts.
