diff --git a/contributors/people.toml b/contributors/people.toml
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -61,3 +61,8 @@ display_name = "Matt Smith-Burlage"
 
 [people.ProdByBuddha]
 sync_github_name = true
+
+[people.sanderdewijs]
+real_name = "Sander de Wijs"
+display_name = "Sander de Wijs"
+sync_github_name = false
diff --git a/crates/engine/src/cmd/instrument.rs b/crates/engine/src/cmd/instrument.rs
--- a/crates/engine/src/cmd/instrument.rs
+++ b/crates/engine/src/cmd/instrument.rs
@@ -1,7 +1,9 @@
 //! An instrument track's instrument (`mix.instrument`): the plugin that turns the track's MIDI
 //! into audio, built in or hosted (CLAP, VST3, Audio Units). The mixer plays `Track::instrument`;
-//! the instrument is otherwise only set when the track is created (`track.new`).
-use super::{CommandSpec, bad, has_selection, str_param, tracks_required};
+//! the instrument is otherwise only set when the track is created (`track.new`). Its parameters
+//! (`mix.instrument_param`, or `mix.insert_param` with `slot: "instrument"`) are applied on top
+//! of a hosted instrument's stored state, like an insert's.
+use super::{CommandSpec, bad, has_selection, param_id, str_param, tracks_required};
 
 use crate::{Engine, Result, cmd};
 use serde_json::{Value, json};
@@ -23,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
             "Set Instrument Parameter",
             [],
             None,
-            "{track?, param, value}: one parameter of an instrument track's built-in instrument (clamped into range)",
+            "{track?, param: id (string or number), value}: one parameter of an instrument track's instrument, built in or hosted (clamped into range); the same as mix.insert_param with slot \"instrument\"",
             has_selection,
             set_instrument_param
         ),
@@ -75,12 +77,11 @@ fn set_instrument(e: &mut Engine, p: &Value) -> Result<Value> {
 
 fn set_instrument_param(e: &mut Engine, p: &Value) -> Result<Value> {
     let id = "mix.instrument_param";
-    let param = str_param(p, "param").ok_or_else(|| bad(id, "`param` required"))?.to_string();
+    let param = param_id(p, "param").ok_or_else(|| bad(id, "`param` required (a string, or a whole number)"))?;
     let value = p.get("value").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(id, "`value` must be a number"))? as f32;
     let t = tracks_required(e, id, p)?.first().copied().ok_or_else(|| bad(id, "no track"))?;
     let ins = e.session_mut().track_mut(t).and_then(|tr| tr.instrument.as_mut()).ok_or_else(|| bad(id, "the track has no instrument"))?;
-    // Hosted instruments keep their parameters in their own state.
-    let info = soundcraft_dsp::plugin_info(&ins.plugin).ok_or_else(|| bad(id, format!("`{}` is not a built-in instrument", ins.plugin)))?;
+    let info = super::clap::plugin_info(&ins.plugin).ok_or_else(|| bad(id, format!("unknown plugin `{}`", ins.plugin)))?;
     let pi = info.param(&param).ok_or_else(|| bad(id, format!("`{}` has no parameter `{param}`", info.id)))?;
     let v = pi.clamp(value);
     ins.params.insert(param.clone(), v);
diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs
--- a/crates/engine/src/cmd/mix.rs
+++ b/crates/engine/src/cmd/mix.rs
@@ -1,6 +1,7 @@
 //! Mixer, insert, send and automation commands.
 use super::{
-    CommandSpec, always, bad, bool_or, f32_or, has_range, has_selection, has_tracks, i64_or, position_param, range_param, str_param, tracks_required,
+    CommandSpec, always, bad, bool_or, f32_or, has_range, has_selection, has_tracks, i64_or, param_id, position_param, range_param, str_param,
+    tracks_required,
 };
 
 use crate::{Engine, EngineError, Result, cmd};
@@ -97,13 +98,21 @@ pub fn specs() -> Vec<CommandSpec> {
         cmd!("mix.insert", "Insert Plugin", [], None, "{track?, slot?: 0..9 | a..j, plugin: id, params?: {id: value}}", has_selection, insert),
         cmd!("mix.insert_remove", "Remove Insert", [], None, "{track?, slot}", has_selection, insert_remove),
         cmd!("mix.insert_bypass", "Bypass Insert", [], None, "{track?, slot, value?: bool}", has_selection, insert_bypass),
-        cmd!("mix.insert_param", "Set Plugin Parameter", [], None, "{track?, slot, param, value}", has_selection, insert_param),
+        cmd!(
+            "mix.insert_param",
+            "Set Plugin Parameter",
+            [],
+            None,
+            "{track?, slot: 0..9 | a..j | \"instrument\", param: id (string or number), value}",
+            has_selection,
+            insert_param
+        ),
         cmd!(
             "mix.insert_params",
             "Set Plugin Parameters",
             [],
             None,
-            "{track?, slot, params: {id: value}, preset?: name} (unknown ids and non-numeric values are errors)",
+            "{track?, slot: 0..9 | a..j | \"instrument\", params: {id: value}, preset?: name} (unknown ids and non-numeric values are errors)",
             has_selection,
             insert_params
         ),
@@ -138,7 +147,7 @@ pub fn specs() -> Vec<CommandSpec> {
             "Add Automation Breakpoint",
             [],
             None,
-            "{track?, param: volume|pan|mute|send_a_level|plugin:a:<id>, at, value, from?}",
+            "{track?, param: volume|pan|mute|send_a_level|plugin:a:<id>|plugin:instrument:<id>, at, value, from?}",
             has_selection,
             set_point
         ),
@@ -312,6 +321,30 @@ fn track_slot(e: &Engine, p: &Value, cmd: &str, max: usize) -> Result<(TrackId,
     Ok((t, slot))
 }
 
+/// The plugin a parameter command addresses: an insert slot, or the instrument of an
+/// instrument or MIDI track (`slot: "instrument"`).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum PluginSlot {
+    Insert(usize),
+    Instrument,
+}
+
+fn track_plugin_slot(e: &Engine, p: &Value, cmd: &str) -> Result<(TrackId, PluginSlot)> {
+    if p.get("slot").and_then(Value::as_str).is_some_and(|s| s.trim().eq_ignore_ascii_case("instrument")) {
+        let t = tracks_required(e, cmd, p)?.first().copied().ok_or_else(|| bad(cmd, "no track"))?;
+        return Ok((t, PluginSlot::Instrument));
+    }
+    track_slot(e, p, cmd, INSERT_SLOTS).map(|(t, i)| (t, PluginSlot::Insert(i)))
+}
+
+fn plugin_mut<'a>(s: &'a mut Session, t: TrackId, slot: PluginSlot, cmd: &str) -> Result<&'a mut Insert> {
+    let tr = s.track_mut(t).ok_or_else(|| bad(cmd, "no track"))?;
+    match slot {
+        PluginSlot::Insert(i) => tr.mixer.inserts.get_mut(i).and_then(Option::as_mut).ok_or_else(|| bad(cmd, "empty slot")),
+        PluginSlot::Instrument => tr.instrument.as_mut().ok_or_else(|| bad(cmd, "the track has no instrument")),
+    }
+}
+
 /// An optional `slot`: absent (or null) means "first free"; a present but invalid value is
 /// an error, never silently treated as absent.
 fn optional_slot(p: &Value, cmd: &str, max: usize) -> Result<Option<usize>> {
@@ -339,7 +372,7 @@ fn insert(e: &mut Engine, p: &Value) -> Result<Value> {
             let Some(pi) = info.params.iter().find(|x| x.id == k) else {
                 return Err(bad("mix.insert", format!("`{plugin}` has no parameter `{k}`")));
             };
-            let val = v.as_f64().map_or(pi.default, |f| (f as f32).clamp(pi.min, pi.max));
+            let val = v.as_f64().map_or(pi.default, |f| pi.clamp(f as f32));
             ins.params.insert(k.clone(), val);
         }
     }
@@ -373,17 +406,15 @@ fn insert_bypass(e: &mut Engine, p: &Value) -> Result<Value> {
 }
 
 fn insert_param(e: &mut Engine, p: &Value) -> Result<Value> {
-    let (t, slot) = track_slot(e, p, "mix.insert_param", INSERT_SLOTS)?;
-    let param = str_param(p, "param").ok_or_else(|| bad("mix.insert_param", "`param` required"))?.to_string();
+    let (t, slot) = track_plugin_slot(e, p, "mix.insert_param")?;
+    let param = param_id(p, "param").ok_or_else(|| bad("mix.insert_param", "`param` required (a string, or a whole number)"))?;
     let value =
         p.get("value").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("mix.insert_param", "`value` must be a number"))? as f32;
-    let s = e.session_mut();
-    let ins =
-        s.track_mut(t).and_then(|tr| tr.mixer.inserts.get_mut(slot)).and_then(Option::as_mut).ok_or_else(|| bad("mix.insert_param", "empty slot"))?;
+    let ins = plugin_mut(e.session_mut(), t, slot, "mix.insert_param")?;
     let info = super::clap::plugin_info(&ins.plugin).ok_or_else(|| bad("mix.insert_param", "unknown plugin"))?;
     let pi =
         info.params.iter().find(|x| x.id == param).ok_or_else(|| bad("mix.insert_param", format!("`{}` has no parameter `{param}`", info.id)))?;
-    let v = value.clamp(pi.min, pi.max);
+    let v = pi.clamp(value);
     ins.params.insert(param, v);
     Ok(json!({"value": v}))
 }
@@ -391,15 +422,10 @@ fn insert_param(e: &mut Engine, p: &Value) -> Result<Value> {
 /// Apply many parameters at once (preset recall); values are clamped. Unknown ids and
 /// non-numeric values are errors, not silent no-ops: a typo must not look like success.
 fn insert_params(e: &mut Engine, p: &Value) -> Result<Value> {
-    let (t, slot) = track_slot(e, p, "mix.insert_params", INSERT_SLOTS)?;
+    let (t, slot) = track_plugin_slot(e, p, "mix.insert_params")?;
     let obj = p.get("params").and_then(Value::as_object).cloned().ok_or_else(|| bad("mix.insert_params", "`params` must be an object"))?;
     let preset = str_param(p, "preset").map(str::to_string);
-    let s = e.session_mut();
-    let ins = s
-        .track_mut(t)
-        .and_then(|tr| tr.mixer.inserts.get_mut(slot))
-        .and_then(Option::as_mut)
-        .ok_or_else(|| bad("mix.insert_params", "empty slot"))?;
+    let ins = plugin_mut(e.session_mut(), t, slot, "mix.insert_params")?;
     let info = super::clap::plugin_info(&ins.plugin).ok_or_else(|| bad("mix.insert_params", "unknown plugin"))?;
     let mut unknown: Vec<&str> = Vec::new();
     let mut non_numeric: Vec<&str> = Vec::new();
@@ -421,7 +447,7 @@ fn insert_params(e: &mut Engine, p: &Value) -> Result<Value> {
     let mut n = 0;
     for (k, v) in obj {
         if let (Some(pi), Some(f)) = (info.params.iter().find(|x| x.id == k), v.as_f64().filter(|f| f.is_finite())) {
-            ins.params.insert(k, (f as f32).clamp(pi.min, pi.max));
+            ins.params.insert(k, pi.clamp(f as f32));
             n += 1;
         }
     }
@@ -533,9 +559,15 @@ pub(crate) fn current_value(tr: &soundcraft_model::Track, param: &AutoParam) ->
         AutoParam::SendLevel(i) => tr.mixer.sends.get(usize::from(*i)).and_then(Option::as_ref).map_or(-144.0, |s| s.level_db),
         AutoParam::SendPan(i) => tr.mixer.sends.get(usize::from(*i)).and_then(Option::as_ref).map_or(0.0, |s| s.pan),
         AutoParam::SendMute(i) => tr.mixer.sends.get(usize::from(*i)).and_then(Option::as_ref).map_or(0.0, |s| f32::from(u8::from(s.mute))),
-        AutoParam::Plugin { slot, param } => {
-            tr.mixer.inserts.get(usize::from(*slot)).and_then(Option::as_ref).and_then(|i| i.params.get(param).copied()).unwrap_or(0.0)
-        }
+        // A parameter the session holds no value for (a hosted instrument made by `track.new`)
+        // starts from the plugin's default, not 0.
+        AutoParam::Plugin { slot, param } => tr.plugin_at(*slot).map_or(0.0, |i| {
+            i.params
+                .get(param)
+                .copied()
+                .or_else(|| super::clap::plugin_info(&i.plugin).and_then(|info| info.param(param)).map(|pi| pi.default))
+                .unwrap_or(0.0)
+        }),
     }
 }
 
@@ -1118,4 +1150,115 @@ mod tests {
         let r = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Verb"})).unwrap();
         assert_eq!(r["slot"], 0);
     }
+
+    fn instrument_params(e: &Engine, t: TrackId) -> std::collections::BTreeMap<String, f32> {
+        e.session().track(t).and_then(|tr| tr.instrument.as_ref()).map(|i| i.params.clone()).unwrap_or_default()
+    }
+
+    #[test]
+    fn the_instrument_slot_takes_parameters_like_an_insert() {
+        let mut e = Engine::default();
+        e.execute("track.new", &json!({"kind": "instrument"})).unwrap();
+        let t = e.session().tracks[0].id;
+        let r = e.execute("mix.insert_param", &json!({"track": t.0, "slot": "Instrument", "param": "cutoff", "value": 5000})).unwrap();
+        assert_eq!(r["value"], 5000.0);
+        assert_eq!(instrument_params(&e, t).get("cutoff"), Some(&5000.0));
+        let r = e.execute("mix.insert_params", &json!({"track": t.0, "slot": "instrument", "params": {"cutoff": 1.0e9, "level": -12}})).unwrap();
+        assert_eq!(r["set"], 2);
+        assert_eq!(instrument_params(&e, t).get("cutoff"), Some(&20_000.0), "clamped");
+        assert_eq!(instrument_params(&e, t).get("level"), Some(&-12.0));
+        e.execute("edit.undo", &json!({})).unwrap();
+        assert_eq!(instrument_params(&e, t).get("cutoff"), Some(&5000.0));
+
+        // Same checks as on an insert: unknown ids and bad values change nothing.
+        for bad in [
+            json!({"track": t.0, "slot": "instrument", "param": "nope", "value": 1}),
+            json!({"track": t.0, "slot": "instrument", "param": "cutoff", "value": "open"}),
+            json!({"track": t.0, "slot": "instrument", "param": -3, "value": 1}),
+            json!({"track": t.0, "slot": "instrument", "param": 1.5, "value": 1}),
+            json!({"track": t.0, "slot": "instruments", "param": "cutoff", "value": 1}),
+        ] {
+            assert!(e.execute("mix.insert_param", &bad).is_err(), "{bad}");
+        }
+        assert!(e.execute("mix.insert_params", &json!({"track": t.0, "slot": "instrument", "params": {"nope": 1}})).is_err());
+        assert_eq!(instrument_params(&e, t).get("cutoff"), Some(&5000.0));
+
+        // An audio track has no instrument; other insert commands keep refusing the name.
+        e.execute("track.new", &json!({})).unwrap();
+        let audio = e.session().tracks[1].id.0;
+        let err = e.execute("mix.insert_param", &json!({"track": audio, "slot": "instrument", "param": "cutoff", "value": 1})).unwrap_err();
+        assert!(err.to_string().contains("no instrument"), "{err}");
+        assert!(e.execute("mix.insert_remove", &json!({"track": t.0, "slot": "instrument"})).is_err());
+        assert!(e.execute("mix.insert_bypass", &json!({"track": t.0, "slot": "instrument"})).is_err());
+    }
+
+    #[test]
+    fn instrument_automation_lanes_take_the_stored_value() {
+        let mut e = Engine::default();
+        e.execute("track.new", &json!({"kind": "instrument"})).unwrap();
+        let t = e.session().tracks[0].id;
+        e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff", "value": 800})).unwrap();
+        let param = AutoParam::parse("plugin:instrument:cutoff").unwrap();
+        let tr = e.session().track(t).unwrap();
+        assert_eq!(current_value(tr, &param), 800.0);
+        e.execute("automation.set_point", &json!({"tracks": [t.0], "param": "plugin:instrument:cutoff", "at": 0, "value": 300})).unwrap();
+        let lane = e.session().track(t).and_then(|tr| tr.lane(&param)).map(|l| l.value_at(0, 0.0));
+        assert_eq!(lane, Some(300.0));
+    }
+
+    #[test]
+    fn toggles_and_choices_are_rounded_like_instrument_param() {
+        let mut e = Engine::default();
+        e.execute("track.new", &json!({})).unwrap();
+        let t = e.session().tracks[0].id;
+        e.execute("mix.insert", &json!({"track": t.0, "slot": 0, "plugin": "eq_7band", "params": {"hpf_on": 0.7}})).unwrap();
+        let stored = |e: &Engine, k: &str| e.session().track(t).and_then(|tr| tr.mixer.inserts[0].as_ref()).and_then(|i| i.params.get(k).copied());
+        assert_eq!(stored(&e, "hpf_on"), Some(1.0), "mix.insert");
+        e.execute("mix.insert_param", &json!({"track": t.0, "slot": 0, "param": "hpf_on", "value": 0.3})).unwrap();
+        assert_eq!(stored(&e, "hpf_on"), Some(0.0), "mix.insert_param");
+        e.execute("mix.insert_params", &json!({"track": t.0, "slot": 0, "params": {"hpf_slope": 1.6}})).unwrap();
+        assert_eq!(stored(&e, "hpf_slope"), Some(2.0), "mix.insert_params");
+    }
+
+    #[test]
+    fn a_lane_written_on_an_unstored_instrument_parameter_starts_from_its_default() {
+        let mut e = Engine::default();
+        e.execute("track.new", &json!({"kind": "instrument"})).unwrap();
+        let t = e.session().tracks[0].id;
+        if let Some(ins) = e.session_mut().track_mut(t).and_then(|tr| tr.instrument.as_mut()) {
+            ins.params.clear();
+        }
+        let default = soundcraft_dsp::plugin_info("subtractive_synth").and_then(|i| i.param("cutoff")).map(|p| p.default).unwrap();
+        e.execute(
+            "automation.write_range",
+            &json!({"tracks": [t.0], "param": "plugin:instrument:cutoff", "start": 48_000, "end": 96_000, "value": 300}),
+        )
+        .unwrap();
+        let param = AutoParam::parse("plugin:instrument:cutoff").unwrap();
+        let lane = e.session().track(t).and_then(|tr| tr.lane(&param)).cloned().unwrap();
+        assert_eq!(lane.value_at(0, f32::NAN), default, "before the range: the default, not 0");
+        assert_eq!(lane.value_at(72_000, f32::NAN), 300.0);
+    }
+
+    /// Apple's DLS synth ships with every Mac: a hosted instrument's parameters can be set by
+    /// numeric id on its slot, which used to answer "not a built-in instrument".
+    #[cfg(target_os = "macos")]
+    #[test]
+    fn a_hosted_instruments_parameters_are_set_by_numeric_id() {
+        let id = "au:aumu:dls :appl";
+        let Some(pi) = super::super::clap::plugin_info(id).and_then(|i| i.params.iter().find(|p| p.unit != soundcraft_dsp::Unit::Choice)) else {
+            return; // no parameters reported on this system
+        };
+        let n: u32 = pi.id.parse().unwrap();
+        let mut e = Engine::default();
+        e.execute("track.new", &json!({"kind": "instrument", "instrument": id})).unwrap();
+        let t = e.session().tracks[0].id;
+        let v = (pi.min + pi.max) / 2.0;
+        e.execute("mix.instrument_param", &json!({"track": t.0, "param": n, "value": v})).unwrap();
+        assert_eq!(instrument_params(&e, t).get(pi.id), Some(&v));
+        e.execute("mix.insert_param", &json!({"track": t.0, "slot": "instrument", "param": n, "value": pi.max})).unwrap();
+        assert_eq!(instrument_params(&e, t).get(pi.id), Some(&pi.max));
+        let auto = format!("plugin:instrument:{n}");
+        e.execute("automation.set_point", &json!({"tracks": [t.0], "param": auto, "at": 0, "value": pi.min})).unwrap();
+    }
 }
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -232,6 +232,13 @@ pub fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
     p.get(key).and_then(Value::as_str)
 }
 
+/// A plugin parameter id: a string, or a whole number in `u32` range (hosted plugins' ids are
+/// decimal `u32`s, such as an Audio Unit's `1575`).
+pub fn param_id(p: &Value, key: &str) -> Option<String> {
+    let v = p.get(key)?;
+    v.as_str().map(str::to_string).or_else(|| v.as_u64().and_then(|n| u32::try_from(n).ok()).map(|n| n.to_string()))
+}
+
 /// Resolve a track from `track` (id number or name) params.
 pub fn track_param(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result<Option<TrackId>> {
     let Some(v) = p.get(key) else { return Ok(None) };
diff --git a/crates/engine/src/cmd/plugin_state.rs b/crates/engine/src/cmd/plugin_state.rs
--- a/crates/engine/src/cmd/plugin_state.rs
+++ b/crates/engine/src/cmd/plugin_state.rs
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
         label: "Store Plugin State",
         menu: &[],
         shortcut: None,
-        params: "{track, slot: 0..9 | \"a\"..\"j\" | \"instrument\", state: base64 | null} → {bytes}. Not undoable and not journaled (states can be large).",
+        params: "{track, slot: 0..9 | \"a\"..\"j\" | \"instrument\", state: base64 | null, params?: {id: number}} → {bytes}. The state is not undoable and not journaled (states can be large). `params`, when a value changes, is stored in that same edit as one undo step and is not journaled.",
         enabled: has_tracks,
         run: insert_state,
         journal: false,
@@ -40,6 +40,62 @@ fn slot_param(p: &Value) -> Result<Option<usize>> {
     }
 }
 
+fn stored_insert(e: &Engine, track: soundcraft_model::TrackId, slot: Option<usize>) -> Option<&soundcraft_model::Insert> {
+    let tr = e.session().track(track)?;
+    match slot {
+        Some(i) => tr.mixer.inserts.get(i).and_then(Option::as_ref),
+        None => tr.instrument.as_ref(),
+    }
+}
+
+fn insert_mut(s: &mut soundcraft_model::Session, track: soundcraft_model::TrackId, slot: Option<usize>) -> Option<&mut soundcraft_model::Insert> {
+    let tr = s.track_mut(track)?;
+    match slot {
+        Some(i) => tr.mixer.inserts.get_mut(i).and_then(Option::as_mut),
+        None => tr.instrument.as_mut(),
+    }
+}
+
+fn state_matches(ins: &soundcraft_model::Insert, state: Option<&[u8]>) -> bool {
+    ins.state_bytes().as_deref() == state.filter(|b| !b.is_empty())
+}
+
+/// Optional `params` on `mix.insert_state`. Absent or empty stores nothing. Keys are parameter
+/// ids; values are finite numbers, clamped the same way as `mix.insert_params`.
+fn param_writes(e: &Engine, track: soundcraft_model::TrackId, slot: Option<usize>, p: &Value) -> Result<Vec<(String, f32)>> {
+    let Some(raw) = p.get("params") else { return Ok(Vec::new()) };
+    let obj = raw.as_object().ok_or_else(|| bad(CMD, "`params` must be an object"))?;
+    if obj.is_empty() {
+        return Ok(Vec::new());
+    }
+    let ins = stored_insert(e, track, slot).ok_or_else(|| bad(CMD, "empty slot"))?;
+    let info = super::clap::plugin_info(&ins.plugin).ok_or_else(|| bad(CMD, "unknown plugin"))?;
+    let mut unknown = Vec::new();
+    let mut non_numeric = Vec::new();
+    for (k, v) in obj {
+        if !info.params.iter().any(|x| x.id == k.as_str()) {
+            unknown.push(k.as_str());
+        } else if v.as_f64().is_none_or(|f| !f.is_finite()) {
+            non_numeric.push(k.as_str());
+        }
+    }
+    if !unknown.is_empty() {
+        unknown.sort_unstable();
+        return Err(bad(CMD, format!("`{}` has no parameter(s): {}", info.id, unknown.join(", "))));
+    }
+    if !non_numeric.is_empty() {
+        non_numeric.sort_unstable();
+        return Err(bad(CMD, format!("parameter(s) need a numeric value: {}", non_numeric.join(", "))));
+    }
+    let mut out = Vec::with_capacity(obj.len());
+    for (k, v) in obj {
+        let Some(pi) = info.params.iter().find(|x| x.id == k.as_str()) else { continue };
+        let Some(f) = v.as_f64().filter(|f| f.is_finite()) else { continue };
+        out.push((k.clone(), pi.clamp(f as f32)));
+    }
+    Ok(out)
+}
+
 fn insert_state(e: &mut Engine, p: &Value) -> Result<Value> {
     let t = track_param(e, CMD, p, "track")?.ok_or_else(|| bad(CMD, "`track` required"))?;
     let slot = slot_param(p)?;
@@ -49,24 +105,40 @@ fn insert_state(e: &mut Engine, p: &Value) -> Result<Value> {
         Some(_) => return Err(bad(CMD, "`state` must be a base64 string or null")),
     };
     let n = state.as_ref().map_or(0, Vec::len);
+    let writes = param_writes(e, t, slot, p)?;
     // Plugins report changes that leave their state as it was: don't modify the session for those.
-    let stored = e.session().track(t).and_then(|tr| match slot {
-        Some(i) => tr.mixer.inserts.get(i).and_then(Option::as_ref),
-        None => tr.instrument.as_ref(),
-    });
-    if stored.is_some_and(|ins| ins.state_bytes().as_deref() == state.as_deref().filter(|b| !b.is_empty())) {
+    // A held parameter that actually moved is part of the same edit, so it does not publish a
+    // second revision on its own.
+    let (unchanged, params_changed) = {
+        let Some(stored) = stored_insert(e, t, slot) else { return Err(bad(CMD, "empty slot")) };
+        let params_changed = writes.iter().any(|(k, v)| stored.params.get(k).copied() != Some(*v));
+        (state_matches(stored, state.as_deref()) && !params_changed, params_changed)
+    };
+    if unchanged {
         return Ok(json!({"bytes": n}));
     }
-    let s = e.session_mut();
-    let tr = s.track_mut(t).ok_or_else(|| bad(CMD, "no track"))?;
-    let ins = match slot {
-        Some(i) => tr.mixer.inserts.get_mut(i).and_then(Option::as_mut),
-        None => tr.instrument.as_mut(),
+    let undo = params_changed.then(|| e.session_arc());
+    {
+        let s = e.session_mut();
+        let ins = insert_mut(s, t, slot).ok_or_else(|| bad(CMD, "empty slot"))?;
+        for (k, v) in &writes {
+            ins.params.insert(k.clone(), *v);
+        }
+        match &state {
+            Some(bytes) => ins.set_state_bytes(bytes),
+            None => ins.state = None,
+        }
     }
-    .ok_or_else(|| bad(CMD, "empty slot"))?;
-    match state {
-        Some(bytes) => ins.set_state_bytes(&bytes),
-        None => ins.state = None,
+    if let Some(mut before) = undo {
+        // State is not undoable. The snapshot was taken before this command, so it still has the
+        // previous bytes; put the bytes just stored back, and let undo restore only the parameters.
+        if let Some(ins) = insert_mut(std::sync::Arc::make_mut(&mut before), t, slot) {
+            match &state {
+                Some(bytes) => ins.set_state_bytes(bytes),
+                None => ins.state = None,
+            }
+        }
+        e.push_undo("Set Plugin Parameters", before);
     }
     Ok(json!({"bytes": n}))
 }
@@ -105,6 +177,69 @@ mod tests {
         assert_eq!(e.session().tracks[0].mixer.inserts[2].as_ref().unwrap().state, None);
     }
 
+    #[test]
+    fn held_params_share_the_state_revision_and_one_undo_step() {
+        let mut e = Engine::default();
+        e.execute("track.new", &json!({"kind": "instrument"})).unwrap();
+        let t = e.session().tracks[0].id.0;
+        let rev = e.revision;
+        let undo = e.undo_history().len();
+        let blob = b64::encode(&[9, 1]);
+        e.execute("mix.insert_state", &json!({"track": t, "slot": "instrument", "state": blob, "params": {"cutoff": 44.0}})).unwrap();
+        assert_eq!(e.revision, rev.wrapping_add(1), "the parameter and the state are one document revision");
+        assert_eq!(e.undo_history().len(), undo + 1);
+        assert_eq!(e.undo_history().last().map(String::as_str), Some("Set Plugin Parameters"));
+        let ins = e.session().tracks[0].instrument.clone().unwrap();
+        assert_eq!(ins.params.get("cutoff").copied(), Some(44.0));
+        assert_eq!(ins.state_bytes().as_deref(), Some(&[9, 1][..]));
+        assert!(e.journal.iter().all(|(id, _)| id != "mix.insert_state"), "not journaled");
+        // The same bytes and the same value publish nothing more.
+        let rev = e.revision;
+        let undo = e.undo_history().len();
+        e.execute("mix.insert_state", &json!({"track": t, "slot": "instrument", "state": blob, "params": {"cutoff": 44.0}})).unwrap();
+        assert_eq!(e.revision, rev);
+        assert_eq!(e.undo_history().len(), undo);
+    }
+
+    #[test]
+    fn undo_restores_parameters_and_keeps_the_new_state() {
+        let mut e = Engine::default();
+        e.execute("track.new", &json!({"kind": "instrument"})).unwrap();
+        let t = e.session().tracks[0].id.0;
+        let old_state = b64::encode(b"old-state");
+        e.execute("mix.insert_state", &json!({"track": t, "slot": "instrument", "state": old_state})).unwrap();
+        e.execute("mix.instrument_param", &json!({"track": t, "param": "cutoff", "value": 1000.0})).unwrap();
+        let new_state = b64::encode(b"new-state");
+        e.execute("mix.insert_state", &json!({"track": t, "slot": "instrument", "state": new_state, "params": {"cutoff": 44.0}})).unwrap();
+        let ins = e.session().tracks[0].instrument.clone().unwrap();
+        assert_eq!(ins.params.get("cutoff").copied(), Some(44.0));
+        assert_eq!(ins.state_bytes().as_deref(), Some(&b"new-state"[..]));
+        assert!(e.undo());
+        let ins = e.session().tracks[0].instrument.clone().unwrap();
+        assert_eq!(ins.params.get("cutoff").copied(), Some(1000.0), "undo restores the parameter");
+        assert_eq!(ins.state_bytes().as_deref(), Some(&b"new-state"[..]), "undo keeps the new state");
+        assert!(e.redo());
+        let ins = e.session().tracks[0].instrument.clone().unwrap();
+        assert_eq!(ins.params.get("cutoff").copied(), Some(44.0));
+        assert_eq!(ins.state_bytes().as_deref(), Some(&b"new-state"[..]));
+
+        // Clearing the state is the new state: undo restores the parameter and leaves it cleared.
+        e.execute("track.new", &json!({})).unwrap();
+        let audio = e.session().tracks.last().unwrap().id;
+        let a = audio.0;
+        e.execute("mix.insert", &json!({"track": a, "plugin": "gain", "slot": 0})).unwrap();
+        let blob = b64::encode(b"gain-state");
+        e.execute("mix.insert_state", &json!({"track": a, "slot": 0, "state": blob})).unwrap();
+        e.execute("mix.insert_param", &json!({"track": a, "slot": 0, "param": "gain", "value": -6.0})).unwrap();
+        let before = e.session().track(audio).unwrap().mixer.inserts[0].as_ref().unwrap().params.get("gain").copied();
+        e.execute("mix.insert_state", &json!({"track": a, "slot": 0, "state": null, "params": {"gain": -12.0}})).unwrap();
+        assert_eq!(e.session().track(audio).unwrap().mixer.inserts[0].as_ref().unwrap().state, None);
+        assert!(e.undo());
+        let ins = e.session().track(audio).unwrap().mixer.inserts[0].as_ref().unwrap();
+        assert_eq!(ins.params.get("gain").copied(), before);
+        assert_eq!(ins.state, None, "undo does not bring the cleared state back");
+    }
+
     #[test]
     fn storing_the_same_state_again_changes_nothing() {
         let mut e = Engine::default();
diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs
--- a/crates/mix/src/lib.rs
+++ b/crates/mix/src/lib.rs
@@ -54,7 +54,8 @@ use soundcraft_dsp::pan::{PanLaw, SpeakerPos, SurroundParams, gains};
 use soundcraft_dsp::{Plugin, SampleSource, Transport, db_to_gain};
 use soundcraft_midi::CtrlKind;
 use soundcraft_model::{
-    AutoParam, BusId, ChannelFormat, Clip, ClipContent, Route, Session, SourceAudio, Speaker, SurroundPan, Track, TrackId, TrackKind,
+    AutoParam, AutomationLane, BusId, ChannelFormat, Clip, ClipContent, INSTRUMENT_AUTO_SLOT, Route, Session, SourceAudio, Speaker, SurroundPan,
+    Track, TrackId, TrackKind,
 };
 use soundcraft_time::{Range, Samples, TICKS_PER_QUARTER};
 use std::collections::HashMap;
@@ -245,6 +246,30 @@ struct BusBuf {
     buf: Vec<Vec<f32>>,
 }
 
+/// One unstored parameter an automation lane is driving.
+///
+/// `param` is an index into `plugin.info().params` (those ids are `'static`, so restoring does
+/// not keep the session's `String`). `lane` is that string's address. `last` is the lane target
+/// last sent, so a steady lane does not write again. `settled` is the value the plugin reported
+/// after that write: a host may clamp or round, and an editor edit is a move away from that
+/// settled value, not away from the raw lane target. `original` is the plugin's value from before
+/// the lane took over; nothing in the session stores it.
+struct LaneHold {
+    param: usize,
+    lane: usize,
+    last: f32,
+    settled: f32,
+    original: f32,
+}
+
+/// Unstored automated parameters a new slot can take before the holds vector has to grow.
+/// Growing past this happens once, on the block a further parameter comes under automation.
+const UNSTORED_HOLD_CAP: usize = 8;
+
+/// Absolute and relative tolerance for "the plugin already has the lane's value". A plugin that
+/// quantizes can sit a hair off what automation sent; an editor move is far larger.
+const PARAM_MATCH_TOLERANCE: f32 = 1.0e-6;
+
 struct PluginSlot {
     id: String,
     /// Channel count the plugin was prepared for.
@@ -252,9 +277,19 @@ struct PluginSlot {
     plugin: Box<dyn Plugin>,
     /// Last values pushed to the plugin, in the insert's parameter-map order.
     applied: Vec<f32>,
+    /// Unstored parameters currently under automation. Looked up by parameter index and lane
+    /// identity, not by position. Reserved for [`UNSTORED_HOLD_CAP`] at creation; it grows only
+    /// when a new hold is pushed.
+    holds: Vec<LaneHold>,
     failed: bool,
 }
 
+impl PluginSlot {
+    fn new(id: String, ch: usize, plugin: Box<dyn Plugin>) -> Self {
+        PluginSlot { id, ch, plugin, applied: Vec::new(), holds: Vec::with_capacity(UNSTORED_HOLD_CAP), failed: false }
+    }
+}
+
 struct Strip {
     buf: Vec<Vec<f32>>,
     /// Pre-fader copy for pre-fader sends.
@@ -476,7 +511,7 @@ impl SlotCtx<'_> {
             let state = if third { ins.state_bytes() } else { None };
             create_instance(&ins.plugin, state.as_deref(), self.sr, self.mb, ch).map(|n| n.plugin)
         }?;
-        Some(PluginSlot { id: ins.plugin.clone(), ch, plugin, applied: Vec::new(), failed: false })
+        Some(PluginSlot::new(ins.plugin.clone(), ch, plugin))
     }
 }
 
@@ -1870,22 +1905,171 @@ fn note_insert_latency(t: &Track, strip: &mut Strip, pos: Samples) {
 }
 
 fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, t: &Track, i: usize, pos: Samples) {
-    // Automation lanes address insert slots; the instrument (`INSTRUMENT_SLOT`) has none, so it
-    // must not pick up slot A's lanes.
-    let Ok(slot_u8) = u8::try_from(i) else {
+    // Automation lanes address insert slots by index and the instrument (`INSTRUMENT_SLOT`) by
+    // `INSTRUMENT_AUTO_SLOT`, so the instrument never picks up slot A's lanes.
+    let slot_u8 = match u8::try_from(i) {
+        _ if i == INSTRUMENT_SLOT => INSTRUMENT_AUTO_SLOT,
+        Ok(s) => s,
+        Err(_) => {
+            // This call is not driving the slot, so a lane that was driving it has let go.
+            release_inactive_holds(slot, |_| false);
+            return apply_values(slot, ins, |_, v| v);
+        }
+    };
+    let reads = t.mixer.automation_mode.reads();
+    let lanes = || {
+        t.automation.iter().filter_map(move |l| match &l.param {
+            AutoParam::Plugin { slot, param } if reads && *slot == slot_u8 && !l.points.is_empty() => Some((param.as_str(), l)),
+            _ => None,
+        })
+    };
+    // Parameters the insert stores go through `apply_values`. The rest live in the plugin (a
+    // hosted plug-in's state blob) and are tracked in `slot.holds`.
+    let unstored = || lanes().filter(|(name, _)| !ins.params.contains_key(*name));
+    if lanes().next().is_none() {
+        // No lane is reading this slot: automation is off, or the lanes are gone. Put unstored
+        // parameters back; the session has no value of its own to restore.
+        release_inactive_holds(slot, |_| false);
         return apply_values(slot, ins, |_, v| v);
+    }
+    if !slot.holds.is_empty() {
+        // A rebuilt session allocates a new parameter `String` for the same lane. Retarget the
+        // hold before releasing, or the lane would look gone and the value it took over from
+        // would be read back as a fresh original.
+        rebind_unstored_holds(slot, unstored());
+        let info = slot.plugin.info();
+        release_inactive_holds(slot, |hold| unstored().any(|(name, _)| hold_is(info, hold, name)));
+    }
+    apply_values(slot, ins, |k, val| lanes().find(|(p, _)| *p == k).map_or(val, |(_, l)| l.value_at(pos, val)));
+    for (name, lane) in unstored() {
+        drive_unstored_lane(slot, name, lane, pos);
+    }
+}
+
+/// Address of the session's parameter id. A new `String` with the same text is a different lane
+/// identity until [`rebind_unstored_holds`] matches it to the parameter index.
+fn lane_identity(param: &str) -> usize {
+    param.as_ptr() as usize
+}
+
+fn plugin_param_index(slot: &PluginSlot, id: &str) -> Option<usize> {
+    slot.plugin.info().params.iter().position(|p| p.id == id)
+}
+
+fn plugin_param_id(slot: &PluginSlot, index: usize) -> Option<&'static str> {
+    slot.plugin.info().params.get(index).map(|p| p.id)
+}
+
+/// Plugin value to put back when the lane lets go. The live value wins; the parameter's default
+/// covers a plugin that does not report it. Non-finite values are not a value we can restore.
+fn read_original(slot: &PluginSlot, id: &str, index: usize) -> f32 {
+    let reported = slot.plugin.param(id).filter(|v| v.is_finite());
+    if let Some(v) = reported {
+        return v;
+    }
+    slot.plugin.info().params.get(index).map(|p| p.default).filter(|v| v.is_finite()).unwrap_or(0.0)
+}
+
+fn live_differs_from_lane(live: f32, lane: f32) -> bool {
+    if live.to_bits() == lane.to_bits() {
+        return false;
+    }
+    let diff = (live - lane).abs();
+    if !diff.is_finite() {
+        return true;
+    }
+    let scale = live.abs().max(lane.abs());
+    diff > PARAM_MATCH_TOLERANCE * (1.0 + scale)
+}
+
+/// Whether `hold` is the hold of lane `name`: the same lane identity AND the same parameter. An
+/// address alone is not enough: the allocator can reuse a dropped lane's address for a lane on
+/// another parameter.
+fn hold_is(info: &soundcraft_dsp::PluginInfo, hold: &LaneHold, name: &str) -> bool {
+    hold.lane == lane_identity(name) && info.params.get(hold.param).is_some_and(|p| p.id == name)
+}
+
+/// Point an existing hold at a lane whose `String` was rebuilt, matching the parameter id to the
+/// cached index. The parameter table is scanned only then, not while the identity is unchanged.
+fn rebind_unstored_holds<'a>(slot: &mut PluginSlot, lanes: impl Iterator<Item = (&'a str, &'a AutomationLane)>) {
+    let info = slot.plugin.info();
+    for (name, _) in lanes {
+        let id = lane_identity(name);
+        if slot.holds.iter().any(|h| hold_is(info, h, name)) {
+            continue;
+        }
+        let Some(param) = plugin_param_index(slot, name) else { continue };
+        if let Some(hold) = slot.holds.iter_mut().find(|h| h.param == param) {
+            hold.lane = id;
+        }
+    }
+}
+
+fn release_inactive_holds(slot: &mut PluginSlot, mut still_active: impl FnMut(&LaneHold) -> bool) {
+    let mut i = 0;
+    while i < slot.holds.len() {
+        let active = match slot.holds.get(i) {
+            Some(hold) => still_active(hold),
+            None => break,
+        };
+        if active {
+            i += 1;
+            continue;
+        }
+        restore_hold(slot, i);
+        slot.holds.swap_remove(i);
+    }
+}
+
+fn restore_hold(slot: &mut PluginSlot, index: usize) {
+    let Some((param, original)) = slot.holds.get(index).map(|h| (h.param, h.original)) else { return };
+    if let Some(id) = plugin_param_id(slot, param) {
+        slot.plugin.set_param(id, original);
+    }
+}
+
+fn drive_unstored_lane(slot: &mut PluginSlot, name: &str, lane: &AutomationLane, pos: Samples) {
+    let id = lane_identity(name);
+    let info = slot.plugin.info();
+    let index = if let Some(i) = slot.holds.iter().position(|h| hold_is(info, h, name)) {
+        i
+    } else if let Some(param) = plugin_param_index(slot, name) {
+        if let Some(i) = slot.holds.iter().position(|h| h.param == param) {
+            if let Some(hold) = slot.holds.get_mut(i) {
+                hold.lane = id;
+            }
+            i
+        } else {
+            // Read the plugin before the first write, so the lane can give the parameter back.
+            let original = read_original(slot, name, param);
+            slot.holds.push(LaneHold { param, lane: id, last: f32::NAN, settled: f32::NAN, original });
+            match slot.holds.len().checked_sub(1) {
+                Some(i) => i,
+                None => return,
+            }
+        }
+    } else {
+        return;
     };
-    let automated = t.mixer.automation_mode.reads()
-        && t.automation.iter().any(|l| matches!(&l.param, AutoParam::Plugin { slot, .. } if *slot == slot_u8) && !l.points.is_empty());
-    apply_values(slot, ins, |k, val| {
-        if automated
-            && let Some(l) = t.automation.iter().find(|l| matches!(&l.param, AutoParam::Plugin { slot, param } if *slot == slot_u8 && param == k))
-            && !l.points.is_empty()
-        {
-            return l.value_at(pos, val);
+    drive_hold(slot, index, lane, pos);
+}
+
+fn drive_hold(slot: &mut PluginSlot, index: usize, lane: &AutomationLane, pos: Samples) {
+    let Some((param, original, last, settled)) = slot.holds.get(index).map(|h| (h.param, h.original, h.last, h.settled)) else { return };
+    let value = lane.value_at(pos, original);
+    let Some(id) = plugin_param_id(slot, param) else { return };
+    let changed = value.to_bits() != last.to_bits();
+    // An editor edit moves the live value off what the plugin settled on. Comparing with the
+    // raw lane target would stay true forever when the plugin clamps or rounds the write.
+    let diverged = slot.plugin.param(id).is_some_and(|live| live_differs_from_lane(live, settled));
+    if changed || diverged {
+        slot.plugin.set_param(id, value);
+        let settled_now = slot.plugin.param(id).unwrap_or(value);
+        if let Some(hold) = slot.holds.get_mut(index) {
+            hold.last = value;
+            hold.settled = settled_now;
         }
-        val
-    });
+    }
 }
 
 /// Sends each stored param (as mapped by `value`) to the plugin when it changed since last time.
@@ -2507,7 +2691,7 @@ mod tests {
 
     use super::*;
     use soundcraft_audio_io::AudioBuffer;
-    use soundcraft_model::{Insert, SourceAudio, SourceId};
+    use soundcraft_model::{AutomationMode, Insert, SourceAudio, SourceId};
     use std::sync::Arc;
 
     fn synth_session(level_db: f32) -> Session {
@@ -2564,6 +2748,259 @@ mod tests {
         assert!((quiet / loud - 0.5).abs() < 0.05, "-12 dB vs -6 dB should halve the level: {quiet} / {loud}");
     }
 
+    #[test]
+    fn instrument_automation_drives_the_instrument() {
+        let quiet = render_range(&synth_session(-48.0), Range::new(0, 12_000), 512);
+        let mut s = synth_session(-6.0);
+        let tr = &mut s.tracks[0];
+        let mut lane = soundcraft_model::AutomationLane::new(AutoParam::Plugin { slot: INSTRUMENT_AUTO_SLOT, param: "level".into() });
+        lane.set_point(0, -48.0);
+        tr.automation.push(lane);
+        assert_eq!(render_range(&s, Range::new(0, 12_000), 512), quiet, "the lane overrides the stored -6 dB");
+    }
+
+    #[test]
+    fn automation_drives_a_parameter_the_session_holds_no_value_for() {
+        // A hosted instrument made by `track.new` starts with an empty parameter map.
+        let quiet = render_range(&synth_session(-48.0), Range::new(0, 12_000), 512);
+        let mut s = synth_session(-6.0);
+        let tr = &mut s.tracks[0];
+        if let Some(ins) = tr.instrument.as_mut() {
+            ins.params.clear();
+        }
+        let mut lane = soundcraft_model::AutomationLane::new(AutoParam::Plugin { slot: INSTRUMENT_AUTO_SLOT, param: "level".into() });
+        lane.set_point(0, -48.0);
+        tr.automation.push(lane);
+        assert_eq!(render_range(&s, Range::new(0, 12_000), 512), quiet);
+    }
+
+    /// Built-in synth with an empty parameter map, the hosted-instrument case: values live on the
+    /// plugin until a lane takes them.
+    fn unstored_synth() -> Option<(PluginSlot, Insert, Session, TrackId)> {
+        let plugin = create_plugin("subtractive_synth")?;
+        let slot = PluginSlot::new("subtractive_synth".into(), 2, plugin);
+        let ins = Insert::new("subtractive_synth");
+        let mut session = Session::default();
+        let track = session.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None);
+        Some((slot, ins, session, track))
+    }
+
+    fn level_lane(value: f32) -> soundcraft_model::AutomationLane {
+        let mut lane = soundcraft_model::AutomationLane::new(AutoParam::Plugin { slot: INSTRUMENT_AUTO_SLOT, param: "level".into() });
+        lane.set_point(0, value);
+        lane
+    }
+
+    #[test]
+    fn a_new_unstored_lane_in_the_place_of_another_is_sent() {
+        // The cache of lane values is per parameter: a different parameter whose value happens to
+        // equal the previous one's must still reach the plugin.
+        let Some((mut slot, ins, mut s, t)) = unstored_synth() else { return };
+        let lane = |param: &str| {
+            let mut l = soundcraft_model::AutomationLane::new(AutoParam::Plugin { slot: INSTRUMENT_AUTO_SLOT, param: param.into() });
+            l.set_point(0, -30.0);
+            l
+        };
+        let tr = s.track_mut(t).unwrap();
+        tr.automation = vec![lane("level")];
+        apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);
+        assert_eq!(slot.plugin.param("level"), Some(-30.0));
+        let cutoff = slot.plugin.param("cutoff");
+        tr.automation = vec![lane("cutoff")];
+        apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);
+        assert_ne!(slot.plugin.param("cutoff"), cutoff, "the cutoff lane reached the plugin");
+    }
+
+    #[test]
+    fn a_reused_lane_address_on_another_parameter_is_not_the_old_hold() {
+        // The allocator may put a new lane's parameter string where a dropped lane's was. Simulate
+        // that: a `level` hold whose lane identity is the address of a new `cutoff` lane.
+        let Some(plugin) = create_plugin("subtractive_synth") else { return };
+        let mut slot = PluginSlot::new("subtractive_synth".into(), 2, plugin);
+        let ins = Insert::new("subtractive_synth");
+        let mut s = Session::default();
+        let t = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None);
+        let lane = |param: &str, v: f32| {
+            let mut l = AutomationLane::new(AutoParam::Plugin { slot: INSTRUMENT_AUTO_SLOT, param: param.into() });
+            l.set_point(0, v);
+            l
+        };
+        let level = slot.plugin.param("level").unwrap();
+        let cutoff = slot.plugin.param("cutoff").unwrap();
+        let tr = s.track_mut(t).unwrap();
+        tr.automation = vec![lane("level", -30.0)];
+        apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);
+        assert_eq!(slot.plugin.param("level"), Some(-30.0));
+        tr.automation = vec![lane("cutoff", 300.0)];
+        if let (Some(AutoParam::Plugin { param, .. }), Some(hold)) = (tr.automation.first().map(|l| &l.param), slot.holds.first_mut()) {
+            hold.lane = lane_identity(param);
+        }
+        apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);
+        assert_eq!(slot.plugin.param("level"), Some(level), "level is given back");
+        assert_eq!(slot.plugin.param("cutoff"), Some(300.0), "cutoff follows its own lane");
+        assert_ne!(cutoff, 300.0);
+    }
+
+    #[test]
+    fn an_unstored_lane_regains_control_after_an_editor_change() {
+        let Some((mut slot, ins, mut session, track)) = unstored_synth() else { return };
+        {
+            let tr = session.track_mut(track).unwrap();
+            tr.automation = vec![level_lane(-30.0)];
+            apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);
+        }
+        assert_eq!(slot.plugin.param("level"), Some(-30.0));
+        // The plug-in editor moves the parameter while the lane stays at -30.
+        slot.plugin.set_param("level", -5.0);
+        assert_eq!(slot.plugin.param("level"), Some(-5.0));
+        let tr = session.track_mut(track).unwrap();
+        apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);
+        assert_eq!(slot.plugin.param("level"), Some(-30.0));
+    }
+
+    /// Rounds every write to an integer and counts `set_param` calls.
+    struct Rounding {
+        inner: Box<dyn Plugin>,
+        value: f32,
+        sets: Arc<std::sync::atomic::AtomicUsize>,
+    }
+
+    impl Plugin for Rounding {
+        fn info(&self) -> &'static soundcraft_dsp::PluginInfo {
+            self.inner.info()
+        }
+        fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {
+            self.inner.prepare(sr, mb, ch);
+        }
+        fn reset(&mut self) {
+            self.inner.reset();
+        }
+        fn set_param(&mut self, id: &str, v: f32) -> bool {
+            if self.inner.info().param(id).is_none() {
+                return false;
+            }
+            self.sets.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+            self.value = v.round();
+            true
+        }
+        fn param(&self, id: &str) -> Option<f32> {
+            self.inner.info().param(id).map(|_| self.value)
+        }
+        fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
+            self.inner.process(io, frames);
+        }
+    }
+
+    #[test]
+    fn a_rounding_plugin_is_written_once_while_the_lane_holds_and_an_editor_edit_retakes_it() {
+        let Some(inner) = create_plugin("gain") else { return };
+        let sets = Arc::new(std::sync::atomic::AtomicUsize::new(0));
+        let value = inner.param("gain").unwrap_or(0.0);
+        let rounding = Rounding { inner, value, sets: Arc::clone(&sets) };
+        let mut slot = PluginSlot::new("gain".into(), 1, Box::new(rounding));
+        let ins = Insert::new("gain");
+        let mut session = Session::default();
+        let track = session.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
+        let mut lane = AutomationLane::new(AutoParam::Plugin { slot: 0, param: "gain".into() });
+        // 0.4 rounds to 0, so the value the plugin settles on is not the lane target.
+        lane.set_point(0, 0.4);
+        {
+            let tr = session.track_mut(track).unwrap();
+            tr.automation = vec![lane];
+            for _ in 0..32 {
+                apply_params(&mut slot, &ins, tr, 0, 0);
+            }
+        }
+        assert_eq!(sets.load(std::sync::atomic::Ordering::Relaxed), 1, "a constant lane writes once");
+        assert_eq!(slot.plugin.param("gain"), Some(0.0));
+        assert_eq!(slot.holds.first().map(|h| h.settled), Some(0.0));
+        assert_eq!(slot.holds.first().map(|h| h.last), Some(0.4));
+
+        // The editor moves the parameter. The lane target is unchanged, so only the live value
+        // differs from what the plugin settled on.
+        assert!(slot.plugin.set_param("gain", 3.2));
+        assert_eq!(sets.load(std::sync::atomic::Ordering::Relaxed), 2);
+        assert_eq!(slot.plugin.param("gain"), Some(3.0));
+        let tr = session.track_mut(track).unwrap();
+        apply_params(&mut slot, &ins, tr, 0, 0);
+        assert_eq!(sets.load(std::sync::atomic::Ordering::Relaxed), 3, "the lane takes the parameter back");
+        assert_eq!(slot.plugin.param("gain"), Some(0.0));
+        for _ in 0..16 {
+            apply_params(&mut slot, &ins, tr, 0, 0);
+        }
+        assert_eq!(sets.load(std::sync::atomic::Ordering::Relaxed), 3, "the retaken lane holds again");
+    }
+
+    #[test]
+    fn removing_an_unstored_lane_restores_the_plugin_value() {
+        let Some((mut slot, ins, mut session, track)) = unstored_synth() else { return };
+        let original = slot.plugin.param("level");
+        {
+            let tr = session.track_mut(track).unwrap();
+            tr.automation = vec![level_lane(-30.0)];
+            apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);
+        }
+        assert_eq!(slot.plugin.param("level"), Some(-30.0));
+        let tr = session.track_mut(track).unwrap();
+        tr.automation.clear();
+        apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);
+        assert_eq!(slot.plugin.param("level"), original);
+        assert!(slot.holds.is_empty());
+    }
+
+    #[test]
+    fn an_unstored_lane_restores_when_reading_stops_and_again_after_reenable() {
+        let Some((mut slot, ins, mut session, track)) = unstored_synth() else { return };
+        let original = slot.plugin.param("level");
+        {
+            let tr = session.track_mut(track).unwrap();
+            tr.automation = vec![level_lane(-30.0)];
+            apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);
+        }
+        assert_eq!(slot.plugin.param("level"), Some(-30.0));
+        {
+            let tr = session.track_mut(track).unwrap();
+            tr.mixer.automation_mode = AutomationMode::Off;
+            apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);
+        }
+        assert_eq!(slot.plugin.param("level"), original, "stopping read restores the pre-lane value");
+        {
+            let tr = session.track_mut(track).unwrap();
+            tr.mixer.automation_mode = AutomationMode::Read;
+            apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);
+        }
+        assert_eq!(slot.plugin.param("level"), Some(-30.0), "the lane takes the parameter again");
+        {
+            let tr = session.track_mut(track).unwrap();
+            tr.mixer.automation_mode = AutomationMode::Off;
+            apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);
+        }
+        assert_eq!(slot.plugin.param("level"), original, "a second stop restores again");
+    }
+
+    #[test]
+    fn unstored_holds_do_not_grow_across_identical_blocks() {
+        let Some((mut slot, ins, mut session, track)) = unstored_synth() else { return };
+        let cap = slot.holds.capacity();
+        assert!(cap >= UNSTORED_HOLD_CAP, "a new slot reserves room for a few unstored holds");
+        {
+            let tr = session.track_mut(track).unwrap();
+            tr.automation = vec![level_lane(-30.0)];
+            apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);
+        }
+        assert_eq!(slot.holds.capacity(), cap, "the first hold fit in the reserved capacity");
+        assert_eq!(slot.holds.len(), 1);
+        let ptr = slot.holds.as_ptr();
+        for _ in 0..100 {
+            let tr = session.track_mut(track).unwrap();
+            apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);
+        }
+        assert_eq!(slot.holds.capacity(), cap, "holds reallocated across identical blocks");
+        assert_eq!(slot.holds.as_ptr(), ptr);
+        assert_eq!(slot.holds.len(), 1);
+        assert_eq!(slot.plugin.param("level"), Some(-30.0));
+    }
+
     #[test]
     fn insert_slot_a_automation_does_not_drive_the_instrument() {
         let mut s = synth_session(-6.0);
diff --git a/crates/model/src/automation.rs b/crates/model/src/automation.rs
--- a/crates/model/src/automation.rs
+++ b/crates/model/src/automation.rs
@@ -58,13 +58,18 @@ pub enum AutoParam {
     SendLevel(u8),
     SendPan(u8),
     SendMute(u8),
-    /// Insert slot + parameter id.
+    /// Insert slot + parameter id. Slot [`INSTRUMENT_AUTO_SLOT`] is the track's instrument;
+    /// `u8::MAX` holds trim.
     Plugin {
         slot: u8,
         param: String,
     },
 }
 
+/// The [`AutoParam::Plugin`] slot of a track's instrument (`plugin:instrument:<id>`), past the
+/// insert slots and next to trim's `u8::MAX`.
+pub const INSTRUMENT_AUTO_SLOT: u8 = u8::MAX - 1;
+
 impl AutoParam {
     pub fn label(&self) -> String {
         match self {
@@ -75,10 +80,12 @@ impl AutoParam {
             AutoParam::SendLevel(i) => format!("send {} level", slot_letter(*i)),
             AutoParam::SendPan(i) => format!("send {} pan", slot_letter(*i)),
             AutoParam::SendMute(i) => format!("send {} mute", slot_letter(*i)),
+            AutoParam::Plugin { slot: INSTRUMENT_AUTO_SLOT, param } => format!("instrument {param}"),
             AutoParam::Plugin { slot, param } => format!("insert {} {param}", slot_letter(*slot)),
         }
     }
-    /// Parse ids like `volume`, `pan`, `pan2`, `mute`, `send_a_level`, `plugin:a:threshold`.
+    /// Parse ids like `volume`, `pan`, `pan2`, `mute`, `send_a_level`, `plugin:a:threshold`,
+    /// `plugin:instrument:1575`.
     pub fn parse(s: &str) -> Option<AutoParam> {
         let s = s.trim().to_ascii_lowercase();
         match s.as_str() {
@@ -100,7 +107,8 @@ impl AutoParam {
         }
         if let Some(rest) = s.strip_prefix("plugin:") {
             let (slot, param) = rest.split_once(':')?;
-            return Some(AutoParam::Plugin { slot: letter_slot(slot)?, param: param.to_string() });
+            let slot = if slot == "instrument" { INSTRUMENT_AUTO_SLOT } else { letter_slot(slot)? };
+            return Some(AutoParam::Plugin { slot, param: param.to_string() });
         }
         None
     }
@@ -339,6 +347,10 @@ mod tests {
     fn param_ids() {
         assert_eq!(AutoParam::parse("send_b_level"), Some(AutoParam::SendLevel(1)));
         assert_eq!(AutoParam::parse("plugin:a:threshold"), Some(AutoParam::Plugin { slot: 0, param: "threshold".into() }));
+        let strum = AutoParam::parse("plugin:Instrument:1575");
+        assert_eq!(strum, Some(AutoParam::Plugin { slot: INSTRUMENT_AUTO_SLOT, param: "1575".into() }));
+        assert_eq!(strum.map(|p| p.label()).as_deref(), Some("instrument 1575"));
+        assert_eq!(AutoParam::parse("plugin:instrument"), None);
         assert_eq!(AutoParam::parse("nope"), None);
         assert_eq!(AutomationMode::from_id("touch/latch"), Some(AutomationMode::TouchLatch));
     }
diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs
--- a/crates/model/src/lib.rs
+++ b/crates/model/src/lib.rs
@@ -15,7 +15,7 @@ mod session;
 mod source;
 mod track;
 
-pub use automation::{AutoParam, AutomationLane, AutomationMode, AutomationPoint};
+pub use automation::{AutoParam, AutomationLane, AutomationMode, AutomationPoint, INSTRUMENT_AUTO_SLOT};
 pub use clip::{Clip, ClipContent, Fade, FadeShape};
 pub use ids::{BusId, ClipId, GroupId, MarkerId, SourceId, TrackId};
 pub use markers::{Group, MarkerKind, MemoryLocation};
diff --git a/crates/model/src/track.rs b/crates/model/src/track.rs
--- a/crates/model/src/track.rs
+++ b/crates/model/src/track.rs
@@ -1,6 +1,6 @@
 //! Tracks and playlists.
 
-use crate::{AutoParam, AutomationLane, ChannelFormat, Clip, ClipId, Mixer, TrackId};
+use crate::{AutoParam, AutomationLane, ChannelFormat, Clip, ClipId, INSTRUMENT_AUTO_SLOT, Insert, Mixer, TrackId};
 use soundcraft_time::{Range, Samples};
 
 /// Track types (Track › New).
@@ -348,6 +348,12 @@ impl Track {
         self.playlist().map_or(&[], |p| p.clips.as_slice())
     }
 
+    /// The plugin an [`AutoParam::Plugin`] slot addresses: an insert, or the instrument
+    /// ([`INSTRUMENT_AUTO_SLOT`]).
+    pub fn plugin_at(&self, slot: u8) -> Option<&Insert> {
+        if slot == INSTRUMENT_AUTO_SLOT { self.instrument.as_ref() } else { self.mixer.inserts.get(usize::from(slot)).and_then(Option::as_ref) }
+    }
+
     pub fn lane(&self, p: &AutoParam) -> Option<&AutomationLane> {
         self.automation.iter().find(|l| &l.param == p)
     }
diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs
--- a/crates/ui-egui/src/edit_window.rs
+++ b/crates/ui-egui/src/edit_window.rs
@@ -2048,8 +2048,8 @@ fn breakpoint_at(s: &Session, tl: Rect, lane: Rect, track: &Track, p: &AutoParam
 
 fn plugin_range(track: &Track, p: &AutoParam) -> (f32, f32) {
     if let AutoParam::Plugin { slot, param } = p
-        && let Some(Some(ins)) = track.mixer.inserts.get(usize::from(*slot))
-        && let Some(info) = soundcraft_dsp::plugin_info(&ins.plugin)
+        && let Some(ins) = track.plugin_at(*slot)
+        && let Some(info) = crate::mix_window::plugin_info(&ins.plugin)
         && let Some(pi) = info.param(param)
     {
         return (pi.min, pi.max);
@@ -2659,6 +2659,9 @@ fn automation_id(p: &AutoParam) -> String {
 
 /// A slot as `AutoParam::parse` reads it: a letter for 0..=25, the number beyond that.
 fn slot_id(i: u8) -> String {
+    if i == soundcraft_model::INSTRUMENT_AUTO_SLOT {
+        return "instrument".into();
+    }
     if i < 26 { char::from(b'a' + i).to_string() } else { i.to_string() }
 }
 
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
@@ -402,6 +402,10 @@ pub struct SoundApp {
     /// Tests supply drained editor reports at the native polling boundary without an audio device.
     #[cfg(test)]
     editor_poll_fixture: Option<EditorPollFixture>,
+    /// Instrument parameters changed in their editors that the session also holds (set by
+    /// command): stored with the instrument's state (see `store_plugin_states`), or by
+    /// `mix.insert_params` when a due capture returns no state.
+    editor_instrument_params: Vec<(TrackId, String, f32)>,
     pub frame_ms: f32,
     last_frame: Option<f64>,
     last_meter_poll: f64,
@@ -444,21 +448,42 @@ fn editor_states_due(
 }
 
 /// Collect one admitted editor poll. Both native hosting and the lifecycle test fixture use
-/// this same gesture scheduler/state-capture route.
+/// this same gesture scheduler/state-capture route. The third value is each due instrument track
+/// whose capture returned no state (the plugin refused `getState()`, or it has no state reader).
 fn editor_changes(
     pending: &mut Vec<(TrackId, usize)>,
     edits: Vec<soundcraft_playback::EditorEdit>,
     changed: Vec<(TrackId, usize)>,
     closed: impl Fn((TrackId, usize)) -> bool,
     capture: impl FnOnce(&[(TrackId, usize)]) -> soundcraft_playback::PluginStates,
-) -> (Vec<soundcraft_playback::EditorEdit>, soundcraft_playback::PluginStates) {
+) -> (Vec<soundcraft_playback::EditorEdit>, soundcraft_playback::PluginStates, Vec<TrackId>) {
     let due = editor_states_due(pending, &edits, changed, closed);
     let states = if due.is_empty() { Vec::new() } else { capture(&due) };
-    (edits, states)
+    let missed = instrument_tracks_without_state(&due, &states);
+    (edits, states, missed)
+}
+
+/// Due instrument tracks that are absent from `states`. A capture that returns `None` omits the
+/// slot, and the held parameters queued for that track still have to reach the session.
+fn instrument_tracks_without_state(due: &[(TrackId, usize)], states: &soundcraft_playback::PluginStates) -> Vec<TrackId> {
+    let mut missed = Vec::new();
+    for &(track, slot) in due {
+        if slot != soundcraft_mix::INSTRUMENT_SLOT {
+            continue;
+        }
+        if states.iter().any(|(t, s, _)| *t == track && *s == slot) {
+            continue;
+        }
+        if !missed.contains(&track) {
+            missed.push(track);
+        }
+    }
+    missed
 }
 
 #[cfg(test)]
-type EditorPollFixture = Box<dyn FnMut(&mut Vec<(TrackId, usize)>) -> (Vec<soundcraft_playback::EditorEdit>, soundcraft_playback::PluginStates)>;
+type EditorPollFixture =
+    Box<dyn FnMut(&mut Vec<(TrackId, usize)>) -> (Vec<soundcraft_playback::EditorEdit>, soundcraft_playback::PluginStates, Vec<TrackId>)>;
 
 /// Whether another automation-write pass is due: the transport moved at least
 /// `step` since `last` in either direction. `last == i64::MIN` marks "never
@@ -554,6 +579,7 @@ impl SoundApp {
             editor_poll_due: true,
             #[cfg(test)]
             editor_poll_fixture: None,
+            editor_instrument_params: Vec::new(),
             frame_ms: 0.0,
             last_frame: None,
             last_meter_poll: f64::NEG_INFINITY,
@@ -788,29 +814,87 @@ impl SoundApp {
     }
 
     /// Reads every live third-party plugin's state and stores it on its insert
-    /// (`mix.insert_state`), so saves and bounces include it.
+    /// (`mix.insert_state`), so saves and bounces include it. Held instrument parameters whose
+    /// plugin returned no state are stored on their own (`mix.insert_params`), not dropped.
     pub fn capture_plugin_states(&mut self) {
-        let Some(p) = &self.player else { return };
-        let states = p.capture_states();
-        self.store_plugin_states(states);
+        if let Some(p) = &self.player {
+            let states = p.capture_states();
+            self.store_plugin_states(states);
+        }
+        // Anything still queued belongs to a track the capture did not return. A save must not
+        // write the session without those values, and `state: null` would clear a stored blob.
+        let queued = self.queued_instrument_tracks();
+        self.persist_queued_instrument_params(&queued);
     }
 
     fn store_plugin_states(&mut self, states: soundcraft_playback::PluginStates) {
         for (t, slot, data) in states {
-            let slot = if slot == soundcraft_mix::INSTRUMENT_SLOT { json!("instrument") } else { json!(slot) };
-            let params = json!({"track": t.0, "slot": slot, "state": soundcraft_model::b64::encode(&data)});
-            // A state read while a knob is dragged (or a pass recorded) joins that gesture, so it
-            // stays one undo step.
-            let stored = match self.engine.merge_key().map(str::to_string) {
-                Some(key) => self.engine.execute_merged("mix.insert_state", &params, &key),
-                None => self.engine.execute("mix.insert_state", &params),
-            };
-            if let Err(e) = stored {
-                log::warn!("storing plugin state: {e}");
+            let instrument = slot == soundcraft_mix::INSTRUMENT_SLOT;
+            let slot = if instrument { json!("instrument") } else { json!(slot) };
+            let mut params = json!({"track": t.0, "slot": slot, "state": soundcraft_model::b64::encode(&data)});
+            let mut edited = Vec::new();
+            if instrument {
+                let (taken, rest): (Vec<_>, Vec<_>) = std::mem::take(&mut self.editor_instrument_params).into_iter().partition(|e| e.0 == t);
+                self.editor_instrument_params = rest;
+                if !taken.is_empty() {
+                    // Same command as the state, so the gesture is one document revision. A value
+                    // that moved is one undo step inside that command.
+                    let values: serde_json::Map<String, Value> = taken.iter().map(|(_, k, v)| (k.clone(), json!(*v))).collect();
+                    params["params"] = json!(values);
+                }
+                edited = taken;
+            }
+            if !self.execute_in_gesture("mix.insert_state", &params) {
+                self.editor_instrument_params.extend(edited);
+            }
+        }
+    }
+
+    /// Tracks that still have held instrument parameters waiting to be stored.
+    fn queued_instrument_tracks(&self) -> Vec<TrackId> {
+        let mut tracks = Vec::new();
+        for (t, _, _) in &self.editor_instrument_params {
+            if !tracks.contains(t) {
+                tracks.push(*t);
+            }
+        }
+        tracks
+    }
+
+    /// Stores queued held parameters for `tracks` with `mix.insert_params` (slot `"instrument"`).
+    /// A failed command puts the values back. Never sends `mix.insert_state` with a null state:
+    /// that would clear the blob the plugin already has.
+    fn persist_queued_instrument_params(&mut self, tracks: &[TrackId]) {
+        for &t in tracks {
+            let (edited, rest): (Vec<_>, Vec<_>) = std::mem::take(&mut self.editor_instrument_params).into_iter().partition(|e| e.0 == t);
+            self.editor_instrument_params = rest;
+            if edited.is_empty() {
+                continue;
+            }
+            let values: serde_json::Map<String, Value> = edited.iter().map(|(_, k, v)| (k.clone(), json!(*v))).collect();
+            let params = json!({"track": t.0, "slot": "instrument", "params": values});
+            if !self.execute_in_gesture("mix.insert_params", &params) {
+                self.editor_instrument_params.extend(edited);
             }
         }
     }
 
+    /// Runs a command that stores what a plugin editor changed. While a knob is dragged (or a pass
+    /// recorded) it joins that gesture, so it stays one undo step. False when the command failed
+    /// (the caller still holds whatever it took out of a queue).
+    fn execute_in_gesture(&mut self, id: &str, params: &Value) -> bool {
+        let stored = match self.engine.merge_key().map(str::to_string) {
+            Some(key) => self.engine.execute_merged(id, params, &key),
+            None => self.engine.execute(id, params),
+        };
+        if let Err(e) = stored {
+            log::warn!("storing plugin state ({id}): {e}");
+            false
+        } else {
+            true
+        }
+    }
+
     /// Opens the insert just picked from its menu: a hosted plugin's own editor, else (a built-in
     /// plugin, no audio engine, or an editor that can't open here) its plugin window.
     fn open_inserted(&mut self) {
@@ -834,7 +918,7 @@ impl SoundApp {
 
     /// Parameter edits made in plugin editors go into the session (one undo step per gesture).
     /// Hosted instruments keep their parameters in their own state, which the session takes
-    /// instead, as it does for plugins that said their state changed: either way the session is
+    /// instead (a parameter the session also holds, set by command, is kept in step), as it does for plugins that said their state changed: either way the session is
     /// modified, so it is autosaved and asks to be saved. The state is read once per gesture (see
     /// `editor_states_due`).
     fn apply_editor_edits(&mut self) {
@@ -843,27 +927,45 @@ impl SoundApp {
         }
         #[cfg(test)]
         if let Some(poll) = self.editor_poll_fixture.as_mut() {
-            let (edits, states) = poll(&mut self.editor_states);
-            self.store_editor_changes(edits, states);
+            let (edits, states, missed) = poll(&mut self.editor_states);
+            self.finish_editor_capture(edits, states, &missed);
             return;
         }
         let Some(p) = &self.player else {
+            // No live plugin left to read. The queued values are still edits the session holds.
+            let queued = self.queued_instrument_tracks();
+            self.persist_queued_instrument_params(&queued);
             self.editor_states.clear();
             return;
         };
-        let (edits, states) = editor_changes(
+        let (edits, states, missed) = editor_changes(
             &mut self.editor_states,
             p.idle(),
             p.changed_states(),
             |(t, slot)| !p.editor_open(t, slot),
             |due| p.capture_states_of(due),
         );
+        self.finish_editor_capture(edits, states, &missed);
+    }
+
+    /// Stores a finished capture. Due instrument tracks missing from `states` (the plugin returned
+    /// no state) persist their queued parameters without touching the stored blob.
+    fn finish_editor_capture(&mut self, edits: Vec<soundcraft_playback::EditorEdit>, states: soundcraft_playback::PluginStates, missed: &[TrackId]) {
         self.store_editor_changes(edits, states);
+        self.persist_queued_instrument_params(missed);
     }
 
     fn store_editor_changes(&mut self, edits: Vec<soundcraft_playback::EditorEdit>, states: soundcraft_playback::PluginStates) {
         for (t, slot, param, value) in edits {
             if slot == soundcraft_mix::INSTRUMENT_SLOT {
+                // The instrument's state carries its edits. A value the session holds as well (set
+                // by command) follows the editor when the state is stored, or it would undo the edit
+                // on top of the state when the session is opened again.
+                let held = self.engine.session().track(t).and_then(|tr| tr.instrument.as_ref()).is_some_and(|i| i.params.contains_key(&param));
+                if held {
+                    self.editor_instrument_params.retain(|(pt, pp, _)| (*pt, pp) != (t, &param));
+                    self.editor_instrument_params.push((t, param, value));
+                }
                 continue;
             }
             let key = format!("param:{}:{slot}:{param}", t.0);
@@ -872,10 +974,11 @@ impl SoundApp {
         self.store_plugin_states(states);
     }
 
-    /// The session is modified, or a plugin editor gesture still holds a state the session hasn't
-    /// taken (see `editor_states_due`): both read the plugins' states before autosaving.
+    /// The session is modified, a plugin editor gesture still holds a state the session hasn't
+    /// taken (see `editor_states_due`), or held instrument parameters are still queued outside
+    /// the document. Closing must not discard that queue.
     fn has_unsaved_changes(&self) -> bool {
-        self.engine.is_dirty() || !self.editor_states.is_empty()
+        self.engine.is_dirty() || !self.editor_states.is_empty() || !self.editor_instrument_params.is_empty()
     }
 
     /// Every two minutes, copy a modified session to the autosave folder (not while recording).
@@ -2261,6 +2364,119 @@ mod tests {
         assert_eq!(instrument_state(&app, keys), Some(vec![59, 1]));
     }
 
+    fn instrument_param(app: &SoundApp, t: TrackId, id: &str) -> Option<f32> {
+        app.engine.session().track(t).and_then(|tr| tr.instrument.as_ref()).and_then(|i| i.params.get(id).copied())
+    }
+
+    #[test]
+    fn an_instrument_parameter_the_session_holds_follows_its_editor_with_the_state() {
+        let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
+        let keys = app.engine.session().track_by_name("Keys").unwrap().id;
+        app.engine.execute("mix.instrument_param", &json!({"track": keys.0, "param": "cutoff", "value": 1000})).unwrap();
+        let steps = app.engine.undo_history().len();
+        for now in 40..45 {
+            editor_frame(&mut app, &[keys], &[], &[], now);
+        }
+        assert_eq!(instrument_param(&app, keys, "cutoff"), Some(1000.0), "mid-gesture the session is left alone");
+        let revision = app.engine.revision;
+        editor_frame(&mut app, &[], &[], &[], 44);
+        assert_eq!(app.engine.revision, revision.wrapping_add(1), "the parameter and the state share one document revision");
+        let cutoff = soundcraft_dsp::plugin_info("subtractive_synth").and_then(|i| i.param("cutoff")).unwrap();
+        assert_eq!(instrument_param(&app, keys, "cutoff"), Some(44f32.clamp(cutoff.min, cutoff.max)), "stored with the state");
+        assert_eq!(instrument_state(&app, keys), Some(vec![44, 1]));
+        assert_eq!(app.engine.undo_history().len(), steps + 1, "one undo step for the gesture");
+        assert!(app.editor_instrument_params.is_empty());
+
+        // A parameter the session doesn't hold stays in the instrument's state only.
+        app.engine.session_mut().track_mut(keys).and_then(|tr| tr.instrument.as_mut()).unwrap().params.remove("cutoff");
+        editor_frame(&mut app, &[keys], &[], &[], 50);
+        editor_frame(&mut app, &[], &[], &[], 50);
+        assert_eq!(instrument_param(&app, keys, "cutoff"), None);
+        assert_eq!(instrument_state(&app, keys), Some(vec![50, 1]));
+    }
+
+    /// A hosted instrument whose `save_state` returns `None` (VST3 `getState` refused) still
+    /// stores the parameters the session holds. The stored state blob is left as it was.
+    #[test]
+    fn held_instrument_params_persist_when_state_capture_returns_none() {
+        use std::cell::Cell;
+        use std::rc::Rc;
+
+        let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
+        let keys = app.engine.session().track_by_name("Keys").unwrap().id;
+        let lead = app.engine.session().track_by_name("Lead").unwrap().id;
+        app.engine.execute("mix.instrument_param", &json!({"track": keys.0, "param": "cutoff", "value": 1000})).unwrap();
+        app.engine.execute("mix.instrument_param", &json!({"track": lead.0, "param": "cutoff", "value": 800})).unwrap();
+        app.engine.session_mut().track_mut(keys).and_then(|tr| tr.instrument.as_mut()).unwrap().set_state_bytes(b"keep");
+        let kept = instrument_state(&app, keys);
+        let phase = Rc::new(Cell::new(0u8));
+        let phase_poll = phase.clone();
+        app.editor_poll_fixture = Some(Box::new(move |pending| {
+            let n = phase_poll.get();
+            phase_poll.set(n + 1);
+            // Frame 0: both gestures are in progress. Frame 1: Keys ends, Lead continues.
+            // Frame 2: Lead ends. The capture returns no state for whatever is due.
+            let edits = match n {
+                0 => vec![
+                    (keys, soundcraft_mix::INSTRUMENT_SLOT, "cutoff".to_string(), 44.0),
+                    (lead, soundcraft_mix::INSTRUMENT_SLOT, "cutoff".to_string(), 55.0),
+                ],
+                1 => vec![(lead, soundcraft_mix::INSTRUMENT_SLOT, "cutoff".to_string(), 56.0)],
+                _ => Vec::new(),
+            };
+            super::editor_changes(pending, edits, Vec::new(), |_| false, |_due| Vec::new())
+        }));
+        let cutoff = soundcraft_dsp::plugin_info("subtractive_synth").and_then(|i| i.param("cutoff")).unwrap();
+        let clamped = |v: f32| v.clamp(cutoff.min, cutoff.max);
+
+        app.editor_poll_due = true;
+        app.apply_editor_edits();
+        assert_eq!(instrument_param(&app, keys, "cutoff"), Some(1000.0), "mid-gesture the session is left alone");
+        assert_eq!(instrument_param(&app, lead, "cutoff"), Some(800.0));
+        assert_eq!(app.editor_instrument_params.len(), 2);
+        assert!(app.has_unsaved_changes(), "queued parameters count as unsaved work");
+
+        app.editor_poll_due = true;
+        app.apply_editor_edits();
+        assert_eq!(instrument_param(&app, keys, "cutoff"), Some(clamped(44.0)), "stored with mix.insert_params");
+        assert_eq!(instrument_state(&app, keys), kept, "a missing state does not clear the stored bytes");
+        assert_eq!(instrument_param(&app, lead, "cutoff"), Some(800.0), "a gesture still in progress stays queued");
+        assert!(app.editor_instrument_params.iter().any(|e| e.0 == lead));
+        assert!(!app.editor_instrument_params.iter().any(|e| e.0 == keys));
+
+        app.editor_poll_due = true;
+        app.apply_editor_edits();
+        assert_eq!(instrument_param(&app, lead, "cutoff"), Some(clamped(56.0)));
+        assert!(app.editor_instrument_params.is_empty(), "no queued entry is left behind");
+    }
+
+    /// Saving (or closing) with no live state reader must not drop parameters already queued.
+    #[test]
+    fn a_save_with_no_state_capture_keeps_queued_instrument_params() {
+        let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
+        let keys = app.engine.session().track_by_name("Keys").unwrap().id;
+        app.engine.execute("mix.instrument_param", &json!({"track": keys.0, "param": "cutoff", "value": 1000})).unwrap();
+        app.engine.session_mut().track_mut(keys).and_then(|tr| tr.instrument.as_mut()).unwrap().set_state_bytes(b"keep");
+        let kept = instrument_state(&app, keys);
+        app.editor_instrument_params.push((keys, "cutoff".to_string(), 44.0));
+        assert!(app.player.is_none(), "this capture has no hosted instrument to ask");
+        app.capture_plugin_states();
+        let cutoff = soundcraft_dsp::plugin_info("subtractive_synth").and_then(|i| i.param("cutoff")).unwrap();
+        assert_eq!(instrument_param(&app, keys, "cutoff"), Some(44.0f32.clamp(cutoff.min, cutoff.max)));
+        assert_eq!(instrument_state(&app, keys), kept);
+        assert!(app.editor_instrument_params.is_empty());
+
+        // The player going away mid-gesture takes the same path through the editor poll.
+        app.editor_instrument_params.push((keys, "cutoff".to_string(), 70.0));
+        app.editor_states.push((keys, soundcraft_mix::INSTRUMENT_SLOT));
+        app.editor_poll_due = true;
+        app.apply_editor_edits();
+        assert_eq!(instrument_param(&app, keys, "cutoff"), Some(70.0f32.clamp(cutoff.min, cutoff.max)));
+        assert!(app.editor_states.is_empty());
+        assert!(app.editor_instrument_params.is_empty());
+        assert_eq!(instrument_state(&app, keys), kept);
+    }
+
     #[test]
     fn two_instrument_editor_drags_store_its_state_twice() {
         let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
@@ -3449,8 +3665,12 @@ mod tests {
         assert_eq!(instrument_state(&app, keys), Some(vec![2, 1]));
         assert_eq!(app.engine.revision, revision.wrapping_add(1), "the completed gesture persists exactly one document revision");
         assert!(app.engine.is_dirty(), "captured instrument state is unsaved document work");
-        assert_eq!(app.engine.undo_history().len(), undo, "state-only instrument persistence does not create an undo step");
-        assert_eq!(app.engine.undo_history(), history, "state capture does not replace another history entry");
+        // The demo instrument already holds cutoff, so the editor value is stored with the state
+        // as one undo step. The blob itself is not journaled, and nothing earlier is replaced.
+        let mut held = history.clone();
+        held.push("Set Plugin Parameters".to_string());
+        assert_eq!(app.engine.undo_history(), held, "a held parameter is one undo step with the state");
+        assert_eq!(app.engine.undo_history().len(), undo + 1);
         assert_eq!(app.engine.journal, journal, "plugin-state blobs are not journaled");
         let settled_revision = app.engine.revision;
         app.raw_input_hook(&ctx, &mut hidden);
@@ -3458,8 +3678,7 @@ mod tests {
         assert_eq!(polls.get(), 4, "a fresh empty host iteration is still live");
         assert_eq!(captures.borrow().len(), 1, "the completed gesture is captured once");
         assert_eq!(app.engine.revision, settled_revision);
-        assert_eq!(app.engine.undo_history().len(), undo);
-        assert_eq!(app.engine.undo_history(), history);
+        assert_eq!(app.engine.undo_history(), held, "a later empty poll does not store the gesture again");
         assert_eq!(app.engine.journal, journal);
     }
 }
diff --git a/docs/plugin-parity.md b/docs/plugin-parity.md
--- a/docs/plugin-parity.md
+++ b/docs/plugin-parity.md
@@ -1,6 +1,6 @@
 # Plugin parity: built-in processors and plugin hosting
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (Auto Pan stereo balance; previously minor: AudioSuite speed ramps (speed_ramp); Auto Pan; Subharmonic; previously minor: Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously trivial: Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (hosted instruments' parameters and automation by command (#1065); previously trivial: Auto Pan stereo balance; previously minor: AudioSuite speed ramps (speed_ramp); Auto Pan; Subharmonic; previously minor: Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously trivial: Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1
 
 Pro Tools users live in plugins: the bundled set covers everyday mixing, and third-party plugins
 cover the rest. This doc tracks both. Our plugins have our own names and are written from
@@ -40,7 +40,7 @@ User report #152: a saved instrument level does not reach the bounce.
 | Plugin format | AAX only (Native and DSP) | CLAP, VST3, Audio Units (macOS) | Users' libraries mostly ship VST3/AU too. AAX (#109) needs Avid's SDK licence and PACE signing: owner decision, likely never |
 | VST2, LV2, LADSPA | — | ❌ (#30) | Beyond Pro Tools; VST2 SDK is no longer licensable |
 | Scan, vendor menus, instrument picker | ✅ | ✅ | #23 |
-| Audio, parameters, automation, latency (PDC) | ✅ | ✅ | |
+| Audio, parameters, automation, latency (PDC) | ✅ | ✅ | Hosted instruments too: `mix.insert_param` with `slot: "instrument"`, `plugin:instrument:<id>` lanes (#1065) |
 | Notes / MIDI to instruments | ✅ | ✅ | But no live MIDI input (G-02) |
 | State saved in sessions, presets | ✅ | ✅ | |
 | Create/destroy off the audio thread | ✅ | ✅ | |
@@ -58,6 +58,7 @@ User report #152: a saved instrument level does not reach the bounce.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-25 | trivial | Hosted instruments' parameters: `mix.insert_param`/`mix.insert_params` take `slot: "instrument"`, `mix.instrument_param` sets hosted instruments, `plugin:instrument:<id>` automation, numeric parameter ids (#1065) |
 | 2026-10-25 | trivial | Auto Pan: constant-power balance of the left and right channels; stereo passes unchanged at the center |
 | 2026-10-24 | minor | AudioSuite speed ramps (`speed_ramp`): down to a stop and up from one, Fit To or Extend, fades on or off |
 | 2026-10-24 | minor | Auto Pan: LFO and envelope panning of stereo tracks; built-in count 27 processors + 3 instruments |
