diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -399,6 +399,10 @@ pub(crate) fn save_to(s: &mut Session, path: &str) -> Result<usize> {
 /// Save to `path` with `opts`. A drawing file then keeps the version and binary choice: the
 /// version goes into the drawing's `$ACADVER`, so later saves (QSAVE) write it again.
 fn save_as(s: &mut Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<usize> {
+    save_as_with(s, cmd, path, opts, store)
+}
+
+fn save_as_with(s: &mut Session, cmd: &str, path: &str, opts: &SaveAs, store: impl FnOnce(&str, &[u8]) -> Result<()>) -> Result<usize> {
     if s.doc()?.refedit.is_some() {
         return Err(bad(cmd, "A block reference is being edited in place: finish with REFCLOSE (Save or Discard) before saving."));
     }
@@ -466,12 +470,31 @@ fn saved(path: &str, bytes: usize) -> Value {
 fn run_save(s: &mut Session, p: &Value) -> Result<Value> {
     let path = match str_param(p, "path") {
         Some(p) => p.to_string(),
-        None => s.state()?.path.clone().ok_or_else(|| bad("qsave", "drawing has no file name yet; use saveas {path}"))?,
+        None => match s.state()?.path.clone() {
+            Some(path) => path,
+            None => return save_opened_name(s),
+        },
     };
     let n = save_to(s, &path)?;
     Ok(saved(&path, n))
 }
 
+/// QSAVE of a drawing opened from bytes (no path) where the host takes delivery of files (the
+/// web embed): deliver it under the name it was opened with, as SAVEAS does. Without a host, or
+/// for a file that isn't a drawing (an imported SVG), there is no name to save under.
+fn save_opened_name(s: &mut Session) -> Result<Value> {
+    let title = s.state()?.title.clone();
+    let deliver = deliver().filter(|_| is_drawing_file(&title));
+    let Some(deliver) = deliver else {
+        return Err(bad("qsave", "drawing has no file name yet; use saveas {path}"));
+    };
+    let opts = SaveAs { version: None, binary: s.state()?.binary_dxf && is_dxf(&title) };
+    let n = save_as_with(s, "save", &title, &opts, |name, bytes| {
+        deliver(name, "application/octet-stream", bytes).map_err(|e| bad("save", format!("{name}: {e}")))
+    })?;
+    Ok(saved(&title, n))
+}
+
 fn run_saveas(s: &mut Session, p: &Value) -> Result<Value> {
     let path = str_param(p, "path").ok_or_else(|| bad("saveas", "`path` is required"))?.to_string();
     let opts = save_options("saveas", p)?;
diff --git a/crates/engine/tests/save_name_only.rs b/crates/engine/tests/save_name_only.rs
new file mode 100644
--- /dev/null
+++ b/crates/engine/tests/save_name_only.rs
@@ -0,0 +1,32 @@
+//! A drawing the host opened from bytes has a name but no path: where files are delivered to a
+//! host (the web embed), QSAVE delivers it under that name, as SAVEAS does.
+
+use cadcraft_engine::Session;
+use cadcraft_engine::cmd::file::{IoHooks, set_deliver, set_io};
+use cadcraft_engine::doc::Drawing;
+use serde_json::json;
+use std::sync::Mutex;
+
+static DELIVERED: Mutex<Vec<(String, Vec<u8>)>> = Mutex::new(Vec::new());
+
+fn deliver(name: &str, _mime: &str, bytes: &[u8]) -> Result<(), String> {
+    DELIVERED.lock().map_err(|e| e.to_string())?.push((name.to_string(), bytes.to_vec()));
+    Ok(())
+}
+
+#[test]
+fn qsave_delivers_a_host_opened_drawing_under_its_name() {
+    set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: |_, _| Ok(b"exported".to_vec()), plot: None });
+    set_deliver(deliver);
+    let mut s = Session::empty();
+    s.execute("open", &json!({"data": "MA==", "name": "plan.dxf"})).unwrap();
+    s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
+    s.execute("qsave", &json!({})).expect("QSAVE delivers the opened drawing");
+    let got = DELIVERED.lock().unwrap().clone();
+    assert_eq!(got, vec![("plan.dxf".to_string(), b"exported".to_vec())]);
+    assert!(!s.state().unwrap().is_dirty());
+
+    // An imported SVG is not a drawing file: QSAVE still asks for a name.
+    s.execute("open", &json!({"data": "MA==", "name": "Logo.svg"})).unwrap();
+    assert!(s.execute("qsave", &json!({})).is_err());
+}
