diff --git a/apps/wordcraft-web/src/host.rs b/apps/wordcraft-web/src/host.rs
--- a/apps/wordcraft-web/src/host.rs
+++ b/apps/wordcraft-web/src/host.rs
@@ -19,11 +19,15 @@
 //!     Export Page as PNG: `false`. They are copies; the document's file stays the same.
 //! - `wordcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
 //! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start.
+//! - `wordcraft:request` (from the parent): `{ id, method, params }`, one of [`HOST_METHODS`] of
+//!   the control protocol (`docs/control-protocol.md`), run on the open document as an agent's
+//!   request is. `wordcraft:response` (to the parent) answers it: `{ id, reply }`, where `reply`
+//!   is the protocol's `{ ok, result }` or `{ ok: false, error }`.
 //!
 //! Only the parent window on the page's own origin is heard or answered. In host mode the parent
 //! guards leaving the page, and `?author=` names the user for comments and tracked changes.
 
-use std::cell::Cell;
+use std::cell::{Cell, RefCell};
 use std::rc::Rc;
 
 use wasm_bindgen::JsCast as _;
@@ -34,6 +38,13 @@ use wordcraft_ui_egui::{Inbox, Services, WordApp};
 /// The longest user name taken from `?author=`.
 const MAX_AUTHOR_CHARS: usize = 200;
 
+/// The control-protocol methods a host page may call: the document's commands and state, not
+/// the window's input, screenshots or quitting.
+pub const HOST_METHODS: &[&str] = &["engine.execute", "engine.commands", "document.inspect", "ui.inspect"];
+
+/// A host request waiting for the next frame: its id, method and parameters.
+pub type Requests = Rc<RefCell<Vec<(JsValue, String, serde_json::Value)>>>;
+
 #[derive(Clone)]
 pub struct Bridge {
     window: web_sys::Window,
@@ -65,8 +76,8 @@ impl Bridge {
 
     /// Hand saved and exported files to the parent instead of downloading them, report unsaved
     /// changes to it, and hear the files it sends.
-    pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
-        self.listen(inbox, ctx);
+    pub fn connect(&self, services: &mut Services, inbox: Inbox, requests: Requests, ctx: egui::Context) {
+        self.listen(inbox, requests, ctx);
         let bridge = self.clone();
         services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
         // Anything else that would download goes to the parent too.
@@ -115,8 +126,15 @@ impl Bridge {
             .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
     }
 
-    /// Hear `wordcraft:open` from the parent and queue the file like a dropped one.
-    fn listen(&self, inbox: Inbox, ctx: egui::Context) {
+    /// Answer a `wordcraft:request` with the control protocol's reply.
+    pub fn post_response(&self, id: &JsValue, reply: &serde_json::Value) {
+        let reply = js_sys::JSON::parse(&reply.to_string()).unwrap_or(JsValue::NULL);
+        self.send(&[("type", "wordcraft:response".into()), ("id", id.clone()), ("reply", reply)]);
+    }
+
+    /// Hear `wordcraft:open` from the parent and queue the file like a dropped one, and queue
+    /// `wordcraft:request` for the next frame, which runs it on the app.
+    fn listen(&self, inbox: Inbox, requests: Requests, ctx: egui::Context) {
         let parent = self.parent.clone();
         let origin = self.origin.clone();
         let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
@@ -124,6 +142,19 @@ impl Bridge {
                 return;
             }
             let data = event.data();
+            if string(&data, "type").as_deref() == Some("wordcraft:request") {
+                let id = js_sys::Reflect::get(&data, &JsValue::from_str("id")).unwrap_or(JsValue::NULL);
+                let method = string(&data, "method").unwrap_or_default();
+                let params = js_sys::Reflect::get(&data, &JsValue::from_str("params"))
+                    .ok()
+                    .and_then(|p| js_sys::JSON::stringify(&p).ok())
+                    .and_then(|s| s.as_string())
+                    .and_then(|s| serde_json::from_str(&s).ok())
+                    .unwrap_or(serde_json::Value::Null);
+                requests.borrow_mut().push((id, method, params));
+                ctx.request_repaint();
+                return;
+            }
             if string(&data, "type").as_deref() != Some("wordcraft:open") {
                 return;
             }
diff --git a/apps/wordcraft-web/src/main.rs b/apps/wordcraft-web/src/main.rs
--- a/apps/wordcraft-web/src/main.rs
+++ b/apps/wordcraft-web/src/main.rs
@@ -10,7 +10,8 @@
 //! URL flags: `?webgl` forces WebGL2; `?sample` opens the sample document.
 //! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
 //! document to open and stores what WordCraft saves (`host`), and `&author=` names the user for
-//! comments and tracked changes.
+//! comments and tracked changes. The parent page can also run control-protocol requests on the
+//! open document (commands, inspection), so an agent edits it while the person does.
 #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
 
 #[cfg(any(target_arch = "wasm32", test))]
diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs
--- a/apps/wordcraft-web/src/web.rs
+++ b/apps/wordcraft-web/src/web.rs
@@ -69,8 +69,9 @@ pub fn start() {
                     session.system_region = region;
                     let messages = Rc::new(RefCell::new(Vec::new()));
                     let mut services = services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone());
+                    let requests = crate::host::Requests::default();
                     if let Some(bridge) = &app_bridge {
-                        bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
+                        bridge.connect(&mut services, inbox.clone(), requests.clone(), cc.egui_ctx.clone());
                     }
                     let mut app = WordApp::new(session, services);
                     // Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
@@ -99,7 +100,7 @@ pub fn start() {
                         crate::host::Bridge::set_author(&mut app, &author);
                         prefs.keep_own_author(&app.session.author, &own);
                     }
-                    Ok(Box::new(WebShell { app, inbox, messages, prefs }))
+                    Ok(Box::new(WebShell { app, inbox, messages, prefs, bridge: app_bridge.clone(), requests }))
                 }),
             )
             .await;
@@ -134,6 +135,9 @@ struct WebShell {
     inbox: Inbox,
     messages: Rc<RefCell<Vec<String>>>,
     prefs: crate::prefs::PrefsSync,
+    /// The host page in `?host=parent` mode, and its requests waiting for this frame.
+    bridge: Option<crate::host::Bridge>,
+    requests: crate::host::Requests,
 }
 
 fn local_storage() -> Option<web_sys::Storage> {
@@ -154,6 +158,27 @@ impl eframe::App for WebShell {
         for msg in msgs {
             self.app.status(msg);
         }
+        // The host's requests run between frames, as the desktop app's control channel does.
+        let requests = std::mem::take(&mut *self.requests.borrow_mut());
+        if !requests.is_empty()
+            && let Some(bridge) = &self.bridge
+        {
+            for (id, method, params) in requests {
+                let reply = if crate::host::HOST_METHODS.contains(&method.as_str()) {
+                    let (request, _) = wordcraft_ui_egui::ControlRequest::new(method, params);
+                    match wordcraft_ui_egui::control::handle(&mut self.app, ctx, &request) {
+                        wordcraft_ui_egui::control::Outcome::Done(reply) => reply,
+                        wordcraft_ui_egui::control::Outcome::Screenshot { .. } => {
+                            serde_json::json!({"ok": false, "error": "screenshots are not available to the host page"})
+                        }
+                    }
+                } else {
+                    serde_json::json!({"ok": false, "error": format!("the host page cannot call `{method}`")})
+                };
+                bridge.post_response(&id, &reply);
+            }
+            ctx.request_repaint();
+        }
         self.app.logic(ctx);
         // Store changed preferences straight away: a reload or closed tab runs no exit code (#2132).
         if let Some(json) = self.prefs.changed(&self.app) {
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -34,8 +34,8 @@ pub use io::Password;
 pub use keymap::KeyMap;
 pub use ribbon::RibbonLayout;
 pub use session::{
-    CellRange, ColumnBlock, DocInfo, DocState, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, StylesPaneDock, UNDO_LIST_MAX,
-    ViewState, WindowView, blank_document, next_math_session, set_system_locale,
+    CellRange, ColumnBlock, DocInfo, DocState, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, Signature, StylesPaneDock,
+    UNDO_LIST_MAX, ViewState, WindowView, blank_document, next_math_session, set_system_locale,
 };
 /// The outcome a command reports for IME receipt classification.
 ///
diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs
--- a/crates/engine/src/session.rs
+++ b/crates/engine/src/session.rs
@@ -239,4 +239,50 @@ pub struct Session {
 impl Session {
     /// File name of the persisted custom dictionary inside the settings folder.
     pub const USER_DICT_FILE: &str = "custom-dictionary.txt";
+
+    /// The longest author name a command request may give; a longer one is cut.
+    pub const MAX_REQUEST_AUTHOR_CHARS: usize = 200;
+
+    /// The author a command request names in `author`, trimmed and cut to
+    /// [`Self::MAX_REQUEST_AUTHOR_CHARS`]: `None` when it's absent, null or blank.
+    pub fn request_author(params: &Value) -> Result<Option<String>, String> {
+        match params.get("author") {
+            None | Some(Value::Null) => Ok(None),
+            Some(Value::String(name)) => {
+                let name: String = name.trim().chars().take(Self::MAX_REQUEST_AUTHOR_CHARS).collect();
+                Ok((!name.is_empty()).then_some(name))
+            }
+            Some(_) => Err("`author` must be a string".to_string()),
+        }
+    }
+
+    /// Signs what follows with `author`: comments and tracked changes carry that name, and the
+    /// initials drawn from it. Returns the session's own signature for [`Self::restore_signature`].
+    pub fn sign_as(&mut self, author: String) -> Signature {
+        Signature { author: std::mem::replace(&mut self.author, author), initials: std::mem::take(&mut self.initials) }
+    }
+
+    /// Puts back the signature [`Self::sign_as`] replaced.
+    pub fn restore_signature(&mut self, own: Signature) {
+        self.author = own.author;
+        self.initials = own.initials;
+    }
+
+    /// Runs `f` signed by `author` (see [`Self::sign_as`]), then restores the session's own
+    /// signature whatever `f` returns; `None` runs `f` as the session's author.
+    pub fn as_author<T>(&mut self, author: Option<String>, f: impl FnOnce(&mut Self) -> T) -> T {
+        let Some(author) = author else { return f(self) };
+        let own = self.sign_as(author);
+        let result = f(self);
+        self.restore_signature(own);
+        result
+    }
+}
+
+/// The author name and comment initials a session signs with, kept aside while a request signs
+/// as someone else.
+#[derive(Debug, Clone)]
+pub struct Signature {
+    author: String,
+    initials: String,
 }
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
@@ -13,6 +13,7 @@ mod bidi;
 mod documents;
 mod fonts;
 mod ime_noop;
+mod request_author;
 mod revision_checks;
 mod typing;
 mod wordart;
diff --git a/crates/engine/src/tests/request_author.rs b/crates/engine/src/tests/request_author.rs
new file mode 100644
--- /dev/null
+++ b/crates/engine/src/tests/request_author.rs
@@ -0,0 +1,50 @@
+//! A command request's `author` signs that command's tracked changes and comments, and the session
+//! signs with its own name again afterwards.
+
+use serde_json::{Value, json};
+
+use crate::Session;
+
+fn run(s: &mut Session, id: &str, v: Value) -> Value {
+    s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"))
+}
+
+fn change_authors(s: &mut Session) -> Vec<String> {
+    let list = run(s, "review.changes", json!({}));
+    list.as_array().into_iter().flatten().filter_map(|c| c["author"].as_str().map(str::to_string)).collect()
+}
+
+#[test]
+fn request_author_is_trimmed_and_cut_and_blank_means_none() {
+    assert_eq!(Session::request_author(&json!({})), Ok(None));
+    assert_eq!(Session::request_author(&json!({"author": null})), Ok(None));
+    assert_eq!(Session::request_author(&json!({"author": "   "})), Ok(None));
+    assert_eq!(Session::request_author(&json!({"author": "  Drafting assistant "})), Ok(Some("Drafting assistant".to_string())));
+    let long = "é".repeat(Session::MAX_REQUEST_AUTHOR_CHARS + 50);
+    let cut = Session::request_author(&json!({"author": long})).ok().flatten().unwrap_or_default();
+    assert_eq!(cut.chars().count(), Session::MAX_REQUEST_AUTHOR_CHARS);
+    assert!(Session::request_author(&json!({"author": 42})).is_err());
+}
+
+#[test]
+fn as_author_signs_the_tracked_change_then_restores_the_session_signature() {
+    let mut s = Session::new(wordcraft_doc::Document::new());
+    s.author = "Alice Smith".into();
+    s.initials = "AS".into();
+    run(&mut s, "review.trackChanges", json!({"value": true}));
+    run(&mut s, "text.insert", json!({"text": "mine "}));
+    let r = s.as_author(Some("Drafting assistant".into()), |s| s.run("text.insert", &json!({"text": "agent"})));
+    assert!(r.is_ok(), "{r:?}");
+    assert_eq!(change_authors(&mut s), ["Alice Smith", "Drafting assistant"]);
+    assert_eq!(s.author, "Alice Smith");
+    assert_eq!(s.initials, "AS");
+}
+
+#[test]
+fn as_author_restores_the_signature_when_the_command_fails() {
+    let mut s = Session::new(wordcraft_doc::Document::new());
+    s.author = "Alice Smith".into();
+    let r = s.as_author(Some("Drafting assistant".into()), |s| s.run("no.such.command", &json!({})));
+    assert!(r.is_err());
+    assert_eq!(s.author, "Alice Smith");
+}
diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs
--- a/crates/mcp/src/lib.rs
+++ b/crates/mcp/src/lib.rs
@@ -71,7 +71,8 @@ fn answer_on(session: &mut Session, method: &str, params: Value) -> Result<Value
         "engine.execute" | "command" => {
             let id = params.get("command").and_then(Value::as_str).ok_or("missing `command`")?.to_string();
             let p = params.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));
-            session.run(&id, &p).map_err(|e| e.to_string())
+            let author = Session::request_author(&params)?;
+            session.as_author(author, |s| s.run(&id, &p)).map_err(|e| e.to_string())
         }
         "engine.commands" => Ok(session.registry.describe()),
         "document.inspect" => session.run("document.inspect", &params).map_err(|e| e.to_string()),
diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs
--- a/crates/mcp/src/tests.rs
+++ b/crates/mcp/src/tests.rs
@@ -355,3 +355,20 @@ fn tools_address_an_open_document_by_id() {
     assert_eq!(tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().trim(), "Second");
     assert_eq!(tool(&mut s, "get_text", json!({"doc": first}))["text"].as_str().unwrap().trim(), "First more again");
 }
+
+#[test]
+fn an_agent_signs_its_tracked_changes_with_author() {
+    let mut s = Server::new(Box::new(Headless::default()));
+    tool(&mut s, "new_document", json!({}));
+    tool(&mut s, "execute", json!({"command": "review.trackChanges", "params": {"value": true}}));
+    tool(&mut s, "execute", json!({"command": "text.insert", "params": {"text": "person "}}));
+    tool(&mut s, "execute", json!({"command": "text.insert", "params": {"text": "agent"}, "author": "Drafting assistant"}));
+    tool(&mut s, "execute", json!({"command": "text.insert", "params": {"text": " person again"}}));
+    let changes = tool(&mut s, "execute", json!({"command": "review.changes"}));
+    let authors: Vec<&str> = changes.as_array().unwrap().iter().filter_map(|c| c["author"].as_str()).collect();
+    assert!(authors.contains(&"Drafting assistant"), "{changes}");
+    assert_eq!(authors.iter().filter(|a| **a == "Drafting assistant").count(), 1, "only the signed command: {changes}");
+    let bad =
+        call(&mut s, 14, "tools/call", json!({"name": "execute", "arguments": {"command": "text.insert", "params": {"text": "x"}, "author": 7}}));
+    assert_eq!(bad["result"]["isError"], true, "{bad}");
+}
diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs
--- a/crates/mcp/src/tools.rs
+++ b/crates/mcp/src/tools.rs
@@ -75,8 +75,11 @@ pub fn tool_definitions() -> Value {
         tool(
             "execute",
             "Run a command",
-            "Run one command by id with params, e.g. {command:\"format.bold\"} or {command:\"insert.table\", params:{rows:3, cols:4}}. Commands work on the active document, or on the open document `doc` names. file.new and file.open open another document beside the open ones and make it active; view.switchWindows {id} makes another one active and file.closeDocument {id, force?} closes one.",
-            with_doc(obj(json!({"command": s("Command id"), "params": {"type": "object", "description": "Command parameters"}}), &["command"])),
+            "Run one command by id with params, e.g. {command:\"format.bold\"} or {command:\"insert.table\", params:{rows:3, cols:4}}. Commands work on the active document, or on the open document `doc` names. file.new and file.open open another document beside the open ones and make it active; view.switchWindows {id} makes another one active and file.closeDocument {id, force?} closes one. `author` signs this command's comments and tracked changes, so an agent's edits are told apart from the person's.",
+            with_doc(obj(
+                json!({"command": s("Command id"), "params": {"type": "object", "description": "Command parameters"}, "author": s("Name that signs this command's comments and tracked changes (the document's author when absent)")}),
+                &["command"]
+            )),
             false
         ),
         tool(
@@ -235,7 +238,13 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
             }
         }
         "execute" => match st("command") {
-            Some(c) => wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({})), doc)),
+            Some(c) => {
+                let mut call = json!({"command": c, "params": a.get("params").cloned().unwrap_or(json!({}))});
+                if let (Some(author), Some(o)) = (a.get("author"), call.as_object_mut()) {
+                    o.insert("author".into(), author.clone());
+                }
+                wrap(b.call("engine.execute", addressed(call, doc)))
+            }
             None => ToolResult::error("missing `command`"),
         },
         "batch" => {
diff --git a/crates/ui-egui/src/control/handler.rs b/crates/ui-egui/src/control/handler.rs
--- a/crates/ui-egui/src/control/handler.rs
+++ b/crates/ui-egui/src/control/handler.rs
@@ -158,7 +158,11 @@ fn handle_in(app: &mut WordApp, doc: u64, method: &str, p: &Value) -> Outcome {
                     Err(e) => err(e),
                 };
             }
-            let r = app.session.with_document(doc, |s| s.run(id, &params));
+            let author = match wordcraft_engine::Session::request_author(p) {
+                Ok(author) => author,
+                Err(e) => return err(e),
+            };
+            let r = app.session.with_document(doc, |s| s.as_author(author, |s| s.run(id, &params)));
             // UI requests the command made carry `document`, so the window can tell they aren't
             // about the document it shows.
             for req in std::mem::take(&mut app.session.ui_requests) {
@@ -190,7 +194,17 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O
             if let Some(e) = close_refused(app, id, &params) {
                 return err(e);
             }
-            match app.execute(id, params) {
+            let author = match wordcraft_engine::Session::request_author(p) {
+                Ok(author) => author,
+                Err(e) => return err(e),
+            };
+            // `author` signs only this command: the app signs with its own name again after it.
+            let own = author.map(|author| app.session.sign_as(author));
+            let r = app.execute(id, params);
+            if let Some(own) = own {
+                app.session.restore_signature(own);
+            }
+            match r {
                 Ok(v) => ok(v),
                 Err(e) => err(e),
             }
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -54,7 +54,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key.
 
 | Method | Params | Result |
 |---|---|---|
-| `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) |
+| `engine.execute` | `command`, `params`, `doc?`, `author?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) |
 | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled |
 | `document.inspect` | `text?`, `doc?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages |
 | `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf; `document`: the active document's id; `documents`: the open documents (`id`, `title`, `path`, `dirty`, `active`) |
@@ -103,6 +103,19 @@ window); in the browser, the one the window shows. Commands work on it unless to
 that isn't a whole number from 0 up, is an error and nothing runs. Window commands (`ui.*`) work
 on the active window only: with the id of another document they are an error.
 
+### Signing a command
+
+`engine.execute` takes `author`, a name that signs that one command's comments and tracked changes
+instead of the document's author (File › Options › General), with initials drawn from the name. An
+agent editing beside a person sets it, so Review shows its changes apart from theirs and they can
+be accepted or rejected as a group. The name is trimmed and cut to 200 characters; left out, `null`
+or blank, the command signs as usual, and a value that isn't a string is an error. The app signs
+with its own name again once the command has run.
+
+```json
+{"id": 3, "key": "…", "method": "engine.execute", "params": {"command": "edit.replaceAll", "params": {"text": "30 days", "with": "45 days"}, "author": "Drafting assistant"}}
+```
+
 ### The active window
 
 Input and window methods go to the active window, the one focused last: `ui.click`, `ui.key`,
diff --git a/docs/mcp.md b/docs/mcp.md
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -115,7 +115,7 @@ files it was given rather than guarding against other software on the computer.
 | Tool | What it does |
 |---|---|
 | `list_commands` | every command (filter with `query`) — over 270 of them |
-| `execute` | run one command: `{command: "insert.table", params: {rows: 3, cols: 4}}` |
+| `execute` | run one command: `{command: "insert.table", params: {rows: 3, cols: 4}}`; `author` signs its comments and tracked changes (see [Signing a command](control-protocol.md#signing-a-command)) |
 | `batch` | run several commands in order |
 | `new_document` | blank, sample, letter, resume, report; opens beside the open documents |
 | `open_document` / `save_document` | docx, doc (open only), odt, rtf, html, md, tex, txt, json; save also pdf, png |
