diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs
--- a/apps/effectcraft-cli/src/main.rs
+++ b/apps/effectcraft-cli/src/main.rs
@@ -7,6 +7,7 @@
 //! effectcraft-cli exec --list [--filter TEXT] [--schemas]     list engine commands (as `commands`)
 //! effectcraft-cli run <id> <json> [<id> <json> …]             run several commands in order
 //! effectcraft-cli run --args-file F                           the same, one argument per line of F
+//! effectcraft-cli run --commands FILE|-                         steps {"command", "params"} from FILE or stdin
 //! effectcraft-cli props <comp> <layer> [--flat] [--time S]    a layer's property tree (with paths)
 //! effectcraft-cli get <comp> <layer> <path> [--time S]        read a property
 //! effectcraft-cli set <comp> <layer> <path> <value> [--time S] [--expression E]
@@ -98,6 +99,10 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
   exec <command-id> [--params JSON]        run one engine command (exec --list: list them, as `commands`)
   run <id> <json> [<id> <json> ...]        run several commands in order
   run --args-file F                        the same, one argument per line of F (no command-line length limit)
+  run --commands FILE|-                    steps from FILE, or standard input for -: a JSON array of
+                                           {\"command\": ID, \"params\": {...}} (or \"id\" for \"command\"), or one
+                                           such object per line (JSON Lines); at most 64 MiB and 100000 steps.
+                                           Repeatable; run after the <id> <json> pairs and --args-file
   props <comp> <layer> [--flat] [--time S] a layer's property tree with paths
   get <comp> <layer> <path> [--time S]     read a property
   set <comp> <layer> <path> <value> [--time S] [--expression E]
@@ -151,6 +156,7 @@ const VALUED: &[&str] = &[
     "--depth",
     "--eval",
     "--args-file",
+    "--commands",
     // render
     "--template",
     "--values",
@@ -334,6 +340,87 @@ impl From<String> for Failure {
     }
 }
 
+/// Most bytes `run --commands` reads from a file or standard input: the steps are untrusted input.
+const MAX_COMMANDS_BYTES: u64 = 64 * 1024 * 1024;
+/// Most steps one `run --commands` takes.
+const MAX_COMMANDS_STEPS: usize = 100_000;
+
+/// The text of `--commands source`: the file `source`, or standard input for `-`, refused past
+/// [`MAX_COMMANDS_BYTES`]. This reader, [`parse_commands`] and [`command_step`] are ported from
+/// vectorcraft-cli's (storytold/vectorcraft#3630), with a tuple in place of its `Step` enum.
+fn read_commands(source: &str) -> Result<String, String> {
+    use std::io::Read;
+    let mut bytes = vec![];
+    let read = if source == "-" {
+        std::io::stdin().lock().take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes)
+    } else {
+        std::fs::File::open(source).and_then(|f| f.take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes))
+    };
+    read.map_err(|e| format!("--commands {source}: {e}"))?;
+    if bytes.len() as u64 > MAX_COMMANDS_BYTES {
+        return Err(format!("--commands {source}: more than {} MiB of steps", MAX_COMMANDS_BYTES / (1024 * 1024)));
+    }
+    String::from_utf8(bytes).map_err(|_| format!("--commands {source}: not UTF-8 text"))
+}
+
+/// The steps of `--commands source` from its `text`: a JSON array of `{"command": ID, "params":
+/// {…}}` objects (`"id"` may stand for `"command"`), or one such object per line (JSON Lines, blank
+/// lines skipped). `first` is the run's index of the first of them, which an error names with the
+/// entry's place in the text.
+fn parse_commands(source: &str, text: &str, first: usize) -> Result<Vec<(String, Value)>, String> {
+    // Notepad on Windows starts UTF-8 files with a byte order mark.
+    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
+    let too_many = || format!("--commands {source}: more than {MAX_COMMANDS_STEPS} steps");
+    // Each entry with where it is in the text: its line for JSON Lines.
+    let mut entries: Vec<(Option<usize>, Value)> = vec![];
+    if text.trim_start().starts_with('[') {
+        let v: Value = serde_json::from_str(text).map_err(|e| format!("--commands {source}: {e}"))?;
+        let Value::Array(items) = v else { return Err(format!("--commands {source}: not a JSON array")) };
+        if items.len() > MAX_COMMANDS_STEPS {
+            return Err(too_many());
+        }
+        entries.extend(items.into_iter().map(|v| (None, v)));
+    } else {
+        for (n, line) in text.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) {
+            if entries.len() == MAX_COMMANDS_STEPS {
+                return Err(too_many());
+            }
+            let v = serde_json::from_str(line).map_err(|e| format!("--commands {source}: step {} (line {}): {e}", first + entries.len(), n + 1))?;
+            entries.push((Some(n + 1), v));
+        }
+    }
+    entries
+        .into_iter()
+        .enumerate()
+        .map(|(k, (line, v))| {
+            command_step(v).map_err(|e| {
+                let place = line.map_or_else(|| format!("entry {}", k + 1), |l| format!("line {l}"));
+                format!("--commands {source}: step {} ({place}): {e}", first + k)
+            })
+        })
+        .collect()
+}
+
+/// The command id and params of one `--commands` entry, `{"command": ID, "params": {…}}`.
+fn command_step(v: Value) -> Result<(String, Value), String> {
+    let Value::Object(mut o) = v else { return Err("a step must be an object such as {\"command\": \"comp.new\", \"params\": {}}".into()) };
+    let id = match (o.remove("command"), o.remove("id")) {
+        (Some(Value::String(id)), None) | (None, Some(Value::String(id))) => id,
+        (Some(_), Some(_)) => return Err("give `command` or `id`, not both".into()),
+        (None, None) => return Err("no `command`".into()),
+        _ => return Err("`command` must be a string, a command id".into()),
+    };
+    let params = match o.remove("params") {
+        None | Some(Value::Null) => json!({}),
+        Some(p @ Value::Object(_)) => p,
+        Some(_) => return Err(format!("`params` of {id} must be a JSON object")),
+    };
+    if let Some(key) = o.keys().next() {
+        return Err(format!("unknown key `{key}`: a step has `command` (or `id`) and `params`"));
+    }
+    Ok((id, params))
+}
+
 fn usage_err<T>(m: &str) -> Result<T, Failure> {
     Err(Failure::Usage(m.to_string()))
 }
@@ -524,14 +611,14 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
                 let text = std::fs::read_to_string(f).map_err(|e| Failure::Usage(format!("--args-file {f}: {e}")))?;
                 pairs.extend(text.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from));
             }
-            if pairs.is_empty() {
-                return usage_err("run needs <command-id> [<json>] pairs");
+            let sources = args.opts("--commands");
+            if pairs.is_empty() && sources.is_empty() {
+                return usage_err("run needs <command-id> [<json>] pairs or --commands FILE|-");
             }
-            let mut b = backend(args, true)?;
-            let mut results = vec![];
+            let mut steps = vec![];
             let mut i = 0;
             while i < pairs.len() {
-                let id = &pairs[i];
+                let id = pairs[i].clone();
                 let params = match pairs.get(i + 1).filter(|p| p.trim_start().starts_with('{')) {
                     Some(p) => {
                         i += 1;
@@ -540,9 +627,26 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
                     None => json!({}),
                 };
                 i += 1;
-                let r = b.exec(id, params).map_err(|e| Failure::Error(format!("{id}: {e}")))?;
-                if let Some(e) = failed_render(id, &r) {
-                    return Err(Failure::Error(format!("{id}: {e}")));
+                steps.push((id, params));
+            }
+            if sources.iter().filter(|s| **s == "-").count() > 1 {
+                return usage_err("--commands - is given twice: standard input is read once");
+            }
+            for &source in &sources {
+                let text = read_commands(source).map_err(Failure::Usage)?;
+                steps.extend(parse_commands(source, &text, steps.len()).map_err(Failure::Usage)?);
+            }
+            if steps.is_empty() {
+                return Err(Failure::Usage(format!("--commands {}: no commands", sources.join(", "))));
+            }
+            let mut b = backend(args, true)?;
+            let mut results = vec![];
+            // The step's index closes the message: a generated list repeats ids, and callers match
+            // the leading `<id>: `.
+            for (n, (id, params)) in steps.into_iter().enumerate() {
+                let r = b.exec(&id, params).map_err(|e| Failure::Error(format!("{id}: {e} (step {n})")))?;
+                if let Some(e) = failed_render(&id, &r) {
+                    return Err(Failure::Error(format!("{id}: {e} (step {n})")));
                 }
                 if !json_out {
                     emit(&r, false);
diff --git a/apps/effectcraft-cli/tests/it/cli.rs b/apps/effectcraft-cli/tests/it/cli.rs
--- a/apps/effectcraft-cli/tests/it/cli.rs
+++ b/apps/effectcraft-cli/tests/it/cli.rs
@@ -133,6 +133,78 @@ fn run_reads_arguments_from_a_file() {
     assert!(String::from_utf8_lossy(&out.stderr).contains("--args-file no/such/file.txt"));
 }
 
+/// Run with `stdin` piped in; returns (exit code, stdout, stderr).
+fn run_with_stdin(args: &[&str], stdin: &str) -> (i32, String, String) {
+    let mut child = bin().args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
+    child.stdin.take().unwrap().write_all(stdin.as_bytes()).unwrap();
+    let out = child.wait_with_output().unwrap();
+    (out.status.code().unwrap_or(-1), String::from_utf8_lossy(&out.stdout).into_owned(), String::from_utf8_lossy(&out.stderr).into_owned())
+}
+
+/// `run --commands FILE|-` (#2251) reads `{"command", "params"}` steps from a JSON array or JSON
+/// Lines, from a file or standard input, and runs them as the same positional pairs would: after
+/// the pairs, so a generated list can follow a hand-written prefix. One command whose params are
+/// over Linux's 128 KiB per-argument limit goes through.
+#[test]
+fn run_reads_commands_from_a_file_or_stdin() {
+    let steps = [
+        ("comp.new", json!({"name": "A", "width": 64, "height": 64})),
+        ("layer.newSolid", json!({"width": 8, "height": 8, "name": "One"})),
+        ("layer.newSolid", json!({"width": 8, "height": 8, "name": "Two"})),
+        ("project.summary", json!({})),
+    ];
+    let pairs: Vec<String> = steps.iter().flat_map(|(id, p)| [id.to_string(), p.to_string()]).collect();
+    let pairs: Vec<&str> = pairs.iter().map(String::as_str).collect();
+    let expected = ok_json(&[&["run", "--empty"][..], &pairs[..]].concat());
+    let layers = |v: &Value| v[3]["result"]["items"][0]["layers"].as_u64();
+    assert_eq!(layers(&expected), Some(2), "{expected}");
+
+    let objects: Vec<Value> = steps.iter().map(|(id, p)| json!({"command": id, "params": p})).collect();
+    let array = tmp("commands.json");
+    std::fs::write(&array, serde_json::to_string_pretty(&objects).unwrap()).unwrap();
+    assert_eq!(ok_json(&["run", "--commands", array.to_str().unwrap(), "--empty"]), expected);
+
+    let lines: String = objects.iter().map(|o| format!("{o}\n\n")).collect();
+    let (code, out, err) = run_with_stdin(&["run", "--commands", "-", "--empty", "--json"], &lines);
+    assert_eq!(code, 0, "{err}");
+    assert_eq!(serde_json::from_str::<Value>(&out).unwrap(), expected);
+
+    // Positional pairs first, then the file.
+    let rest = tmp("commands-rest.jsonl");
+    std::fs::write(&rest, objects[1..].iter().map(|o| format!("{o}\n")).collect::<String>()).unwrap();
+    assert_eq!(ok_json(&[&["run", "--empty", "--commands", rest.to_str().unwrap()][..], &pairs[..2]].concat()), expected);
+
+    let name = "n".repeat(140 * 1024);
+    let big = tmp("commands-big.jsonl");
+    std::fs::write(&big, format!("{}\n{}\n", json!({"command": "comp.new", "params": {"name": name}}), json!({"command": "project.summary"}))).unwrap();
+    let v = ok_json(&["run", "--commands", big.to_str().unwrap(), "--empty"]);
+    assert_eq!(v[1]["result"]["items"][0]["name"].as_str().map(str::len), Some(name.len()));
+
+    for (input, args, error) in [
+        ("", &["run", "--commands", "-", "--empty"][..], "--commands -: no commands"),
+        (
+            "{\"command\":\"project.summary\"}\n{\"command\":\"x\",\"param\":{}}\n",
+            &["run", "project.summary", "{}", "--commands", "-", "--empty"],
+            "step 2 (line 2): unknown key `param`",
+        ),
+        ("[{\"command\":\"project.summary\"}, {\"params\":{}}]", &["run", "--commands", "-", "--empty"], "step 1 (entry 2): no `command`"),
+        ("{}", &["run", "--commands", "-", "--commands", "-", "--empty"], "standard input is read once"),
+    ] {
+        let (code, out, err) = run_with_stdin(args, input);
+        assert_eq!(code, 2, "{input:?}: {err}");
+        assert!(err.contains(error), "{input:?}: {err}");
+        assert!(out.is_empty(), "{input:?}: nothing ran");
+    }
+    // An empty list after positional pairs is no error: there is still something to run.
+    let (code, _, err) = run_with_stdin(&["run", "project.summary", "--commands", "-", "--empty"], "");
+    assert_eq!(code, 0, "{err}");
+    // A step that fails when it runs is named by its index too.
+    let (code, out, err) = run_with_stdin(&["run", "--commands", "-", "--empty", "--json"], "{\"command\":\"project.summary\"}\n{\"command\":\"no.such\"}\n");
+    assert_eq!(code, 1, "{err}");
+    let e = serde_json::from_str::<Value>(&out).unwrap()["error"].as_str().map(String::from).unwrap_or_default();
+    assert!(e.starts_with("no.such: ") && e.ends_with(" (step 1)"), "{e}");
+}
+
 #[test]
 fn render_frame_writes_png() {
     let out = tmp("f.png");
diff --git a/docs/agents.md b/docs/agents.md
--- a/docs/agents.md
+++ b/docs/agents.md
@@ -677,6 +677,19 @@ effectcraft-cli exec file.importLottie '{"path":"anim.json"}' main.ecproj --save
 effectcraft-cli render main.ecproj --comp Main --out main.mov --format prores --progress   # progress lines on stderr for a program that reads them
 ```
 
+A long `run` doesn't fit on a command line (Windows caps it at 32 767 characters, Linux one
+argument at 128 KiB). `run --args-file F` reads the same `<id> <json>` arguments one per line;
+`run --commands FILE` reads steps as a JSON array of `{"command": ID, "params": {…}}` objects, or one
+such object per line (JSON Lines), and `--commands -` reads them from standard input. `--commands`
+may be repeated; its steps run after the positional pairs and `--args-file`. A malformed step is a
+usage error naming its index (from 0, as in the results) before anything runs, and the error of a step
+that fails ends with `(step N)`:
+
+```sh
+effectcraft-cli run --commands steps.json --empty --save-as main.ecproj
+generate-steps | effectcraft-cli run main.ecproj --commands - --save --json
+```
+
 Scripts written against After Effects' documented scripting API run with `script`:
 
 ```sh
