diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs
--- a/crates/engine/src/commands.rs
+++ b/crates/engine/src/commands.rs
@@ -3650,11 +3650,17 @@ fn reset_params(e: &mut filmcraft_project::EffectInstance, only: Option<&str>, f
     any
 }
 
+/// `effects.addKeyframe` / `deleteKeyframe` / `moveKeyframe` / `setKeyframe` / `setKeyframeInterpolation`.
+/// An explicit `time` keeps its place, outside the clip too (#1055, as in `effects.setParam`); only
+/// the playhead fallback is pinned to the clip's last tick. `add` replies `{added, mediaTime}`:
+/// whether it added the keyframe or (it is a toggle) removed the one there, and the media time of
+/// that keyframe. `delete` replies `{removed}`: whether a keyframe sat exactly at `mediaTime`.
 fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
     let c = clip_p(p, "clip").ok_or_else(|| bad("keyframe", "need `clip`"))?;
     let pid = str_p(p, "param").ok_or_else(|| bad("keyframe", "need `param`"))?.to_string();
     let eff = p.get("effect").cloned().unwrap_or(json!("motion"));
-    let ph = time_p(s, p, "").unwrap_or(s.playhead());
+    let at = time_p(s, p, "");
+    let playhead = s.playhead();
     let mtime = p.get("mediaTime").and_then(Value::as_i64).map(Tick);
     let to = p.get("to").and_then(Value::as_i64).map(Tick);
     let interp = str_p(p, "interpolation").map(str::to_string);
@@ -3671,8 +3677,9 @@ fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
         s.history.merge_key = None;
     }
     s.edit_sequence_as(label, merge.as_deref(), |q, _, _| {
+        let mut reply = Value::Null;
         let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
-        let mt_now = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
+        let mt_now = it.source_time_at(at.unwrap_or_else(|| playhead.clamp(it.start, (it.end() - Tick(1)).max(it.start))));
         let e = match &eff {
             Value::Number(n) => it.effects.get_mut(n.as_u64().unwrap_or(0) as usize),
             Value::String(sid) => it.effects.iter_mut().find(|e| &e.effect == sid),
@@ -3683,12 +3690,15 @@ fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
         match op {
             "add" => {
                 // toggle: remove if a keyframe sits at the playhead, else add
-                if !prm.remove_keyframe_at(mt_now) {
+                let added = !prm.remove_keyframe_at(mt_now);
+                if added {
                     prm.add_keyframe(mt_now);
                 }
+                reply = json!({"added": added, "mediaTime": mt_now.0});
             }
             "delete" => {
-                prm.remove_keyframe_at(mtime.ok_or_else(|| bad("keyframe", "need `mediaTime`"))?);
+                let removed = prm.remove_keyframe_at(mtime.ok_or_else(|| bad("keyframe", "need `mediaTime`"))?);
+                reply = json!({"removed": removed});
             }
             "move" => {
                 let from = mtime.ok_or_else(|| bad("keyframe", "need `mediaTime`"))?;
@@ -3732,9 +3742,8 @@ fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
                 k.interp = kind;
             }
         }
-        Ok(())
-    })?;
-    Ok(Value::Null)
+        Ok(reply)
+    })
 }
 
 fn in_out_range(s: &Session) -> Result<TimeRange> {
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -326,6 +326,86 @@ fn add_keyframe_toggles_the_keyframe_at_the_playhead() {
     assert_eq!(scale(&s), (0, 100.0));
 }
 
+/// #3386: `effects.addKeyframe` with a `time` at the clip's out point used to put the keyframe a
+/// tick earlier, so a script that then looked for it at the out point found nothing (and
+/// `effects.deleteKeyframe` answered `null` either way). An explicit `time` now keeps its place, as
+/// in `effects.setParam` (#1055); only the playhead fallback is pinned to the clip. The replies say
+/// where the keyframe is (`mediaTime`), whether it was added or toggled off (`added`), and whether a
+/// delete removed anything (`removed`).
+#[test]
+fn add_keyframe_at_the_out_point_stays_there_and_the_replies_say_what_happened() {
+    let mut s = demo();
+    let (c, start, end) = {
+        let it = &s.active_sequence().unwrap().video_tracks[0].items[0];
+        (it.id.0, it.start, it.end())
+    };
+    let clip = |s: &Session| s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(c)).unwrap().1.clone();
+    let media_at = |s: &Session, t: Tick| clip(s).source_time_at(t);
+    let keys = |s: &Session| clip(s).effect("motion").unwrap().params["scale"].keyframes.iter().map(|k| k.time).collect::<Vec<_>>();
+    let add = |s: &mut Session, time: Tick| s.execute("effects.addKeyframe", json!({"clip": c, "effect": "motion", "param": "scale", "time": time.0})).unwrap();
+    let delete = |s: &mut Session, media: Tick| {
+        s.execute("effects.deleteKeyframe", json!({"clip": c, "effect": "motion", "param": "scale", "mediaTime": media.0})).unwrap()
+    };
+    let reply = |r: &Value| (r["added"].clone(), r["mediaTime"].clone());
+
+    let r = add(&mut s, start);
+    assert_eq!(reply(&r), (json!(true), json!(media_at(&s, start).0)), "{r}");
+
+    // at the out point: the keyframe is at the out point, where a script looks for it
+    let out = media_at(&s, end);
+    let r = add(&mut s, end);
+    assert_eq!(reply(&r), (json!(true), json!(out.0)), "{r}");
+    assert_eq!(keys(&s), vec![media_at(&s, start), out]);
+
+    // a delete at a time with no keyframe says so and changes nothing
+    let r = delete(&mut s, out - Tick(1));
+    assert_eq!(r["removed"], json!(false), "{r}");
+    assert_eq!(keys(&s).len(), 2);
+    let r = delete(&mut s, out);
+    assert_eq!(r["removed"], json!(true), "{r}");
+    assert_eq!(keys(&s), vec![media_at(&s, start)]);
+
+    // adding twice at the same time toggles: the second reply says the keyframe was removed
+    assert_eq!(add(&mut s, end)["added"], json!(true));
+    let r = add(&mut s, end);
+    assert_eq!(reply(&r), (json!(false), json!(out.0)), "{r}");
+    assert_eq!(keys(&s), vec![media_at(&s, start)]);
+
+    // past the end it keeps its time too
+    let past = end + (end - start);
+    let r = add(&mut s, past);
+    assert_eq!(reply(&r), (json!(true), json!(media_at(&s, past).0)), "{r}");
+    let past_media = media_at(&s, past);
+    delete(&mut s, past_media);
+
+    // only the playhead fallback is pinned to the clip: a playhead past the end lands on its last tick
+    s.execute("playhead.set", json!({"seconds": past.seconds()})).unwrap();
+    let r = s.execute("effects.addKeyframe", json!({"clip": c, "effect": "motion", "param": "scale"})).unwrap();
+    assert_eq!(reply(&r), (json!(true), json!(media_at(&s, end - Tick(1)).0)), "{r}");
+
+    // hostile times neither panic nor leave a half-made keyframe; a delete that names no time is an error
+    for t in [i64::MIN, i64::MAX] {
+        let _ = s.execute("effects.addKeyframe", json!({"clip": c, "effect": "motion", "param": "scale", "time": t}));
+    }
+    s.active_sequence().unwrap().check().unwrap();
+    assert!(s.execute("effects.deleteKeyframe", json!({"clip": c, "effect": "motion", "param": "scale"})).is_err());
+}
+
+/// The position half of #3386, read from the project and not from a reply: a keyframe asked for at
+/// the clip's out point is at the out point.
+#[test]
+fn add_keyframe_with_a_time_at_the_out_point_is_not_moved() {
+    let mut s = demo();
+    let (c, end) = {
+        let it = &s.active_sequence().unwrap().video_tracks[0].items[0];
+        (it.id.0, it.end())
+    };
+    s.execute("effects.addKeyframe", json!({"clip": c, "effect": "motion", "param": "scale", "time": end.0})).unwrap();
+    let it = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(c)).unwrap().1.clone();
+    let at: Vec<Tick> = it.effect("motion").unwrap().params["scale"].keyframes.iter().map(|k| k.time).collect();
+    assert_eq!(at, vec![it.source_time_at(end)], "the keyframe sits at the out point, not a tick before it");
+}
+
 /// `effects.setParam` writes a keyframe only on an animated parameter. An agent that sends a value
 /// with a `time` to a parameter that is not animated gets a static value (the result says so:
 /// 0 keyframes); `"keyframe": true` is the stopwatch and the value in one step.
diff --git a/docs/agents.md b/docs/agents.md
--- a/docs/agents.md
+++ b/docs/agents.md
@@ -48,6 +48,12 @@ edit reports the value written at the requested time, rather than the static fal
 For example, setting opacity `blend` to `"Screen"` returns
 `{"keyframes": 0, "param": "blend", "value": 8}`.
 
+`effects.addKeyframe` toggles: it returns `{"added": true, "mediaTime": t}` when it added a keyframe and
+`{"added": false, "mediaTime": t}` when it removed the one already there; `mediaTime` is where the keyframe
+is, to find it again. An explicit `time` keeps its place, outside the clip too, as in `effects.setParam`;
+only the playhead (when no `time` is given) is held to the clip's last tick. `effects.deleteKeyframe`
+returns `{"removed": true}` or `{"removed": false}` (no keyframe sat exactly at `mediaTime`).
+
 ### From an installed release
 
 The release packages ship `filmcraft-cli` alongside the desktop app, so no build is needed:
