diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -590,6 +590,15 @@ dependencies = [
  "serde_json",
 ]
 
+[[package]]
+name = "craft-ask-agent"
+version = "0.1.0"
+dependencies = [
+ "egui",
+ "serde",
+ "serde_json",
+]
+
 [[package]]
 name = "craft-atomic"
 version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -20,6 +20,7 @@ hayro-interpret = { version = "=0.7.0", default-features = false, features = ["e
 hayro-syntax = { version = "=0.7.2", default-features = false, features = ["std"] }
 bytemuck = "1"
 thiserror = "2"
+craft-ask-agent = { path = "crates/ask-agent" }
 craft-atomic = { path = "crates/atomic" }
 craft-cms = { path = "crates/cms" }
 craft-docpath = { path = "crates/docpath" }
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -26,6 +26,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
 - [`craft-ai-core`](crates/ai-core/README.md): the data model and contract for AI in the Craft Apps (connections, models, tools, requests, jobs, consent, history, route spec). Pure, no I/O, wasm-safe. Contract in [docs/ai-shared-layer.md](docs/ai-shared-layer.md).
 - [`craft-pdf`](crates/pdf/README.md): independently optional PDF parsing, interpretation, rasterization and guarded authoring. Apps retain layout, fonts, editing and export policy; writer-only consumers do not enable the shared reader.
 
+- [`craft-ask-agent`](crates/ask-agent/README.md): Ask Agent, the person's own agent working in the document from a selection and an instruction: the shared request queue, edit rule and control methods, and the egui chip and bubble. Selection, undo, authorship and holds stay in each app.
 - [`craft-atomic`](crates/atomic/README.md): Crash-safe atomic file writing and replacement with same-directory staging, directory fsync, Apple `F_FULLFSYNC` fallback, transient lock backoff, and fault injection for the Craft suite.
 - [`craft-cms`](crates/cms/README.md): pure-Rust ICC v2/v4 colour management: profile parsing
   and writing, transforms, rendering intents, black point compensation, soft proofing, gamut
diff --git a/crates/ask-agent/Cargo.toml b/crates/ask-agent/Cargo.toml
new file mode 100644
--- /dev/null
+++ b/crates/ask-agent/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "craft-ask-agent"
+description = "Ask Agent: hand a selection and an instruction to the person's own agent (MCP / control channel), with the shared request queue and bubble."
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+rust-version.workspace = true
+repository.workspace = true
+
+[features]
+# `ui`: the chip and the bubble for egui apps.
+egui = ["dep:egui"]
+
+[dependencies]
+egui = { workspace = true, optional = true }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+
+[lints]
+workspace = true
diff --git a/crates/ask-agent/README.md b/crates/ask-agent/README.md
new file mode 100644
--- /dev/null
+++ b/crates/ask-agent/README.md
@@ -0,0 +1,44 @@
+# craft-ask-agent
+
+Ask Agent for the Craft apps: the person selects something, types an instruction, and **the agent they
+already run** (Claude Code, Codex, any MCP client) does it in the document. The app calls no model and
+holds no key.
+
+## What is shared
+
+- `hub` (always): the request queue (`ask`, `listen`, `reply`, `close`), the agents listening (`agents`,
+  `colour`), the one edit rule (`may_edit`: while a request is open only the connection working on it
+  edits, and only for it), what is held (`held`), and the control methods `agent.listen` /
+  `agent.reply` (`AgentHub::handle`, called from the control channel's connection threads). Refusals
+  carry stable codes: `HELD`, `NOT_OWNER`, `NO_REQUEST`, `INVALID_PARAMS`, `READ_ONLY_REQUEST`.
+- `ui` (feature `egui`): `chip` beside the selection and `bubble` (instruction, agent picker, progress
+  notes, answer, Keep / Undo / follow-up, and a compact Actions menu). egui's visuals only; the app passes its translation function.
+
+## What stays in each app
+
+The app adapter supplies the selection and document as JSON, grouping the agent's edits into one undo step,
+authoring them "Agent (name)", refusing the person's edits that touch a held target, and a closing
+comment with the agent's answer.
+
+## App-specific action shortcuts
+
+`ui::View::actions` accepts a slice of `ui::QuickAction { label, group, instruction }`.
+An empty `group` places an item at the menu root; other values form native submenus.
+Choosing an action only fills the editable instruction. The person still presses Send.
+The app owns its presets and their wording; the shared UI adds no document operation or
+permission. Prompts are suggestions, not enforcement of read-only or signing policy.
+
+## Visible conversation
+
+`ui::View::history` contains the prior instruction/answer pairs and `instruction` is the current request.
+The bounded, collapsible Conversation panel exposes these exchanges in the bubble without changing
+the wire protocol. Each app records the instruction with the agent's answer in its native comments
+or notes so that the context remains after Keep. This is conversation context, not document version history.
+
+## Wire protocol
+
+- `agent.listen {timeoutMs?, agent?}` → `{id, instruction, history, selection, document, agent}` or `{none: true}`
+- `agent.reply {id, text, done?, error?}` → `{id, done}`
+- MCP tools in each app: `wait_for_request`, `reply_to_request`.
+
+Used by WordCraft, GridCraft, DeckCraft and PdfCraft.
diff --git a/crates/ask-agent/src/hub.rs b/crates/ask-agent/src/hub.rs
new file mode 100644
--- /dev/null
+++ b/crates/ask-agent/src/hub.rs
@@ -0,0 +1,338 @@
+//! The request queue: requests from the window, the agents that listen for them, and who may edit.
+//! No egui and no app types, so the control channel threads can share it.
+
+use std::collections::HashMap;
+use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError};
+use std::time::Duration;
+
+use serde::{Deserialize, Serialize};
+use serde_json::{Value, json};
+
+pub const NOT_OWNER: &str = "NOT_OWNER";
+pub const NO_REQUEST: &str = "NO_REQUEST";
+pub const INVALID_PARAMS: &str = "INVALID_PARAMS";
+pub const READ_ONLY_REQUEST: &str = "READ_ONLY_REQUEST";
+pub const HELD: &str = "HELD";
+
+/// Longest `agent.listen` wait, and the wait when none is given.
+pub const MAX_LISTEN: Duration = Duration::from_secs(60);
+pub const DEFAULT_LISTEN: Duration = Duration::from_secs(30);
+
+/// A refusal with a stable code.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct HubError {
+    pub code: &'static str,
+    pub message: String,
+}
+
+impl std::fmt::Display for HubError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}: {}", self.code, self.message)
+    }
+}
+
+impl std::error::Error for HubError {}
+
+pub fn err(code: &'static str, message: impl Into<String>) -> HubError {
+    HubError { code, message: message.into() }
+}
+
+/// One earlier exchange in a thread of follow-ups.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Turn {
+    pub instruction: String,
+    pub answer: String,
+}
+
+/// What the agent receives: the instruction, the earlier turns, the selection and the document.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct Request {
+    pub id: u64,
+    pub instruction: String,
+    pub history: Vec<Turn>,
+    pub selection: Value,
+    pub document: Value,
+    /// The agent asked for by name; `None` for any.
+    pub agent: Option<String>,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Status {
+    Queued,
+    Working { conn: u64 },
+    Answered,
+    Failed,
+}
+
+/// What the window shows for a request.
+#[derive(Clone, Debug)]
+pub struct Snapshot {
+    pub id: u64,
+    pub status: Status,
+    pub agent: Option<String>,
+    pub notes: Vec<String>,
+    pub answer: Option<String>,
+    pub error: Option<String>,
+}
+
+struct Entry {
+    request: Request,
+    shown: Snapshot,
+}
+
+#[derive(Default)]
+struct Inner {
+    last_id: u64,
+    entries: Vec<Entry>,
+    /// Connections listening, with the name each gave.
+    listeners: HashMap<u64, String>,
+}
+
+impl Inner {
+    fn entry(&mut self, id: u64) -> Result<&mut Entry, HubError> {
+        self.entries.iter_mut().find(|e| e.request.id == id).ok_or_else(|| err(NO_REQUEST, format!("no Ask Agent request {id}")))
+    }
+}
+
+/// The queue, shared by the window and the control channel's connection threads.
+#[derive(Clone, Default)]
+pub struct AgentHub(Arc<(Mutex<Inner>, Condvar)>);
+
+impl AgentHub {
+    fn lock(&self) -> MutexGuard<'_, Inner> {
+        self.0.0.lock().unwrap_or_else(PoisonError::into_inner)
+    }
+
+    /// Queue a request from the window; returns its id.
+    pub fn ask(&self, mut request: Request) -> u64 {
+        let mut inner = self.lock();
+        inner.last_id += 1;
+        request.id = inner.last_id;
+        let shown = Snapshot { id: request.id, status: Status::Queued, agent: None, notes: vec![], answer: None, error: None };
+        inner.entries.push(Entry { request, shown });
+        self.0.1.notify_all();
+        inner.last_id
+    }
+
+    /// Wait up to `wait` for a request this agent may take, and take it.
+    pub fn listen(&self, conn: u64, name: Option<&str>, wait: Duration, alive: impl Fn() -> bool) -> Option<Request> {
+        let name = name.map(str::trim).filter(|n| !n.is_empty()).unwrap_or("Agent").to_string();
+        // Counted down in short steps rather than read from a clock, which wasm builds lack.
+        let mut left = wait;
+        let mut inner = self.lock();
+        inner.listeners.insert(conn, name.clone());
+        loop {
+            let free = |e: &&mut Entry| e.shown.status == Status::Queued && e.request.agent.as_ref().is_none_or(|a| *a == name);
+            if let Some(e) = inner.entries.iter_mut().find(free) {
+                e.shown.status = Status::Working { conn };
+                e.shown.agent = Some(name);
+                return Some(e.request.clone());
+            }
+            if left.is_zero() || !alive() {
+                return None;
+            }
+            let step = left.min(Duration::from_millis(250));
+            inner = self.0.1.wait_timeout(inner, step).unwrap_or_else(PoisonError::into_inner).0;
+            left -= step;
+        }
+    }
+
+    /// A progress note (`done` false), the answer, or a failure, from the agent working on `id`.
+    pub fn reply(&self, conn: u64, id: u64, text: &str, done: bool, error: Option<&str>) -> Result<(), HubError> {
+        let mut inner = self.lock();
+        let shown = &mut inner.entry(id)?.shown;
+        if shown.status != (Status::Working { conn }) {
+            return Err(err(NOT_OWNER, format!("Ask Agent request {id} is not yours: listen for it first")));
+        }
+        match (error, done) {
+            (Some(e), _) => (shown.status, shown.error) = (Status::Failed, Some(e.into())),
+            (None, true) => (shown.status, shown.answer) = (Status::Answered, Some(text.into())),
+            (None, false) => shown.notes.push(text.into()),
+        }
+        Ok(())
+    }
+
+    /// The one edit rule: while a request is open, only the connection working on it edits, for it.
+    pub fn may_edit(&self, conn: u64, request: Option<u64>) -> Result<(), HubError> {
+        let mut inner = self.lock();
+        let open = inner.entries.iter().any(|e| matches!(e.shown.status, Status::Queued | Status::Working { .. }));
+        match request {
+            None if open => Err(err(HELD, "the document is held for an Ask Agent request: pass its `request` id")),
+            None => Ok(()),
+            Some(id) => match inner.entry(id)?.shown.status {
+                Status::Working { conn: c } if c == conn => Ok(()),
+                Status::Answered | Status::Failed => Err(err(READ_ONLY_REQUEST, format!("Ask Agent request {id} is finished"))),
+                _ => Err(err(NOT_OWNER, format!("Ask Agent request {id} is not yours: listen for it first"))),
+            },
+        }
+    }
+
+    /// The selection of each request being worked on, with its agent's name.
+    pub fn held(&self) -> Vec<(Value, String)> {
+        let inner = self.lock();
+        let working = inner.entries.iter().filter(|e| matches!(e.shown.status, Status::Working { .. }));
+        working.map(|e| (e.request.selection.clone(), e.shown.agent.clone().unwrap_or_default())).collect()
+    }
+
+    /// The connection is gone: its unanswered request goes back in the queue.
+    pub fn disconnect(&self, conn: u64) {
+        let mut inner = self.lock();
+        inner.listeners.remove(&conn);
+        for e in inner.entries.iter_mut().filter(|e| e.shown.status == Status::Working { conn }) {
+            (e.shown.status, e.shown.agent) = (Status::Queued, None);
+        }
+        self.0.1.notify_all();
+    }
+
+    /// Names of the agents listening, sorted, for the picker.
+    pub fn agents(&self) -> Vec<String> {
+        let mut names: Vec<String> = self.lock().listeners.values().cloned().collect();
+        names.sort();
+        names.dedup();
+        names
+    }
+
+    pub fn snapshot(&self, id: u64) -> Option<Snapshot> {
+        self.lock().entries.iter().find(|e| e.request.id == id).map(|e| e.shown.clone())
+    }
+
+    /// The person is done with the request: it leaves the queue.
+    pub fn close(&self, id: u64) {
+        self.lock().entries.retain(|e| e.request.id != id);
+    }
+
+    /// Answer `agent.listen` and `agent.reply` for connection `conn`; `None` for any other method.
+    pub fn handle(&self, conn: u64, method: &str, p: &Value, alive: impl Fn() -> bool) -> Option<Value> {
+        let reply = match method {
+            "agent.listen" => self.handle_listen(conn, p, alive),
+            "agent.reply" => self.handle_reply(conn, p),
+            _ => return None,
+        };
+        Some(match reply {
+            Ok(result) => json!({"ok": true, "result": result}),
+            Err(e) => json!({"ok": false, "error": e.message, "errorCode": e.code}),
+        })
+    }
+
+    fn handle_listen(&self, conn: u64, p: &Value, alive: impl Fn() -> bool) -> Result<Value, HubError> {
+        let wait = match p.get("timeoutMs") {
+            None | Some(Value::Null) => DEFAULT_LISTEN,
+            Some(v) => v.as_u64().map(Duration::from_millis).ok_or_else(|| err(INVALID_PARAMS, "`timeoutMs` is a number of milliseconds"))?,
+        };
+        let request = self.listen(conn, str_param(p, "agent")?, wait.min(MAX_LISTEN), alive);
+        Ok(request.map_or_else(|| json!({"none": true}), |r| json!(r)))
+    }
+
+    fn handle_reply(&self, conn: u64, p: &Value) -> Result<Value, HubError> {
+        let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| err(INVALID_PARAMS, "missing `id`: the request's id"))?;
+        let done = match p.get("done") {
+            None | Some(Value::Null) => true,
+            Some(v) => v.as_bool().ok_or_else(|| err(INVALID_PARAMS, "`done` is true or false"))?,
+        };
+        let (text, error) = (str_param(p, "text")?.unwrap_or(""), str_param(p, "error")?);
+        if text.trim().is_empty() && error.is_none() {
+            return Err(err(INVALID_PARAMS, "missing `text`: what to show in the window"));
+        }
+        self.reply(conn, id, text, done, error)?;
+        Ok(json!({"id": id, "done": done}))
+    }
+}
+
+/// An optional string parameter.
+pub fn str_param<'a>(p: &'a Value, key: &str) -> Result<Option<&'a str>, HubError> {
+    match p.get(key) {
+        None | Some(Value::Null) => Ok(None),
+        Some(Value::String(s)) => Ok(Some(s)),
+        Some(_) => Err(err(INVALID_PARAMS, format!("`{key}` is a string"))),
+    }
+}
+
+/// A stable colour for an agent's name, from a palette readable on light and dark themes.
+pub fn colour(name: &str) -> [u8; 3] {
+    const PALETTE: [[u8; 3]; 8] =
+        [[37, 99, 235], [5, 150, 105], [217, 119, 6], [219, 39, 119], [124, 58, 237], [8, 145, 178], [220, 38, 38], [101, 163, 13]];
+    let hash = name.bytes().fold(0x811c_9dc5_u32, |h, b| (h ^ u32::from(b)).wrapping_mul(0x0100_0193));
+    PALETTE[hash as usize % PALETTE.len()]
+}
+
+#[cfg(test)]
+#[allow(clippy::unwrap_used)]
+mod tests {
+    use super::*;
+
+    const NOW: Duration = Duration::ZERO;
+
+    fn req(text: &str, agent: Option<&str>) -> Request {
+        Request {
+            id: 0,
+            instruction: text.into(),
+            history: vec![],
+            selection: json!({"text": text}),
+            document: json!({}),
+            agent: agent.map(Into::into),
+        }
+    }
+
+    #[test]
+    fn a_request_goes_to_one_agent_who_answers_it() {
+        let hub = AgentHub::default();
+        let id = hub.ask(req("Tighten", None));
+        let taken = hub.listen(1, Some("Claude"), NOW, || true).unwrap();
+        assert_eq!((taken.id, hub.snapshot(id).unwrap().status), (id, Status::Working { conn: 1 }));
+        assert!(hub.listen(2, Some("Codex"), NOW, || true).is_none(), "taken once");
+        hub.reply(1, id, "Halfway", false, None).unwrap();
+        hub.reply(1, id, "Done", true, None).unwrap();
+        let s = hub.snapshot(id).unwrap();
+        assert_eq!(
+            (s.status, s.notes, s.answer.as_deref(), s.agent.as_deref()),
+            (Status::Answered, vec!["Halfway".into()], Some("Done"), Some("Claude"))
+        );
+        assert_eq!(hub.reply(2, id, "x", true, None).unwrap_err().code, NOT_OWNER);
+    }
+
+    #[test]
+    fn only_the_working_connection_edits_while_a_request_is_open() {
+        let hub = AgentHub::default();
+        assert_eq!(hub.may_edit(9, None), Ok(()), "nothing open: anyone edits");
+        let id = hub.ask(req("Fix", None));
+        hub.listen(1, None, NOW, || true).unwrap();
+        for (conn, request, code) in [(1, Some(id), None), (2, Some(id), Some(NOT_OWNER)), (1, None, Some(HELD)), (1, Some(99), Some(NO_REQUEST))] {
+            assert_eq!(hub.may_edit(conn, request).err().map(|e| e.code), code, "{conn} {request:?}");
+        }
+        assert_eq!(hub.held(), vec![(json!({"text": "Fix"}), "Agent".to_string())]);
+        hub.reply(1, id, "Done", true, None).unwrap();
+        assert_eq!(hub.may_edit(1, Some(id)).unwrap_err().code, READ_ONLY_REQUEST);
+        assert!(hub.held().is_empty());
+    }
+
+    #[test]
+    fn a_dropped_agent_hands_its_request_back_and_routing_follows_names() {
+        let hub = AgentHub::default();
+        let id = hub.ask(req("Review", Some("Codex")));
+        assert!(hub.listen(1, Some("Claude"), NOW, || true).is_none(), "asked for Codex");
+        hub.listen(2, Some("Codex"), NOW, || true).unwrap();
+        assert_eq!(hub.agents(), vec!["Claude".to_string(), "Codex".to_string()]);
+        hub.disconnect(2);
+        assert_eq!((hub.snapshot(id).unwrap().status, hub.agents()), (Status::Queued, vec!["Claude".to_string()]));
+        assert_eq!(hub.listen(3, Some("Codex"), NOW, || true).unwrap().id, id);
+    }
+
+    #[test]
+    fn the_wire_methods_validate_their_parameters() {
+        let hub = AgentHub::default();
+        assert_eq!(hub.handle(1, "agent.listen", &json!({"timeoutMs": 0}), || true).unwrap()["result"], json!({"none": true}));
+        let id = hub.ask(req("Go", None));
+        assert_eq!(hub.handle(1, "agent.listen", &json!({"agent": "Claude", "timeoutMs": 0}), || true).unwrap()["result"]["id"], id);
+        for bad in [json!({"text": "x"}), json!({"id": id}), json!({"id": id, "text": 3}), json!({"id": id, "text": "x", "done": "yes"})] {
+            assert_eq!(hub.handle(1, "agent.reply", &bad, || true).unwrap()["errorCode"], INVALID_PARAMS, "{bad}");
+        }
+        assert_eq!(hub.handle(1, "agent.reply", &json!({"id": id, "text": "Done"}), || true).unwrap()["ok"], true);
+        assert!(hub.handle(1, "app.save", &json!({}), || true).is_none());
+    }
+
+    #[test]
+    fn colours_are_stable_and_spread() {
+        assert_eq!(colour("Claude"), colour("Claude"));
+        assert_ne!(colour("Claude"), colour("Codex"));
+    }
+}
diff --git a/crates/ask-agent/src/lib.rs b/crates/ask-agent/src/lib.rs
new file mode 100644
--- /dev/null
+++ b/crates/ask-agent/src/lib.rs
@@ -0,0 +1,19 @@
+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+
+//! Ask Agent for the Craft apps: the person selects something, types an instruction, and the agent
+//! they already run (over MCP or the app's control channel) does it in the document.
+//!
+//! This crate holds what every app shares:
+//! - [`hub`]: the request queue, the agents listening, the one rule for who may edit, what is held,
+//!   and the two control methods `agent.listen` / `agent.reply` ([`AgentHub::handle`]);
+//! - [`ui`] (feature `egui`): the chip beside the selection and the bubble with Keep, Undo and a
+//!   follow-up field.
+//!
+//! Each app keeps its own part: the selection as JSON, its undo grouping, authorship ("Agent (name)")
+//! and what "held" means for its documents. See the README.
+
+pub mod hub;
+#[cfg(feature = "egui")]
+pub mod ui;
+
+pub use hub::{AgentHub, HubError, Request, Snapshot, Status, Turn, colour};
diff --git a/crates/ask-agent/src/ui.rs b/crates/ask-agent/src/ui.rs
new file mode 100644
--- /dev/null
+++ b/crates/ask-agent/src/ui.rs
@@ -0,0 +1,285 @@
+//! The chip and the bubble, in egui's own visuals (each app sets them from its theme). The app passes
+//! its translation function.
+//!
+//! Layout: the chip sits just under the selection, where the bubble then opens. The bubble has three
+//! bands on one left edge: a header (who gets the request, close), the content (conversation, status,
+//! answer), and a footer row (Actions on the left, the primary button on the right). Every control in
+//! the bubble has the same height.
+
+use egui::{Align, Color32, Key, Layout, Rect, RichText, Stroke, pos2, vec2};
+
+use crate::hub::{Snapshot, Status, Turn, colour};
+
+/// The app's translation of an interface string.
+pub type Tr<'a> = &'a dyn Fn(&'static str) -> String;
+
+/// What the person did in the bubble.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum Action {
+    /// The instruction, or a follow-up after an answer.
+    Send(String),
+    Keep,
+    Undo,
+    Close,
+}
+
+/// A shortcut that only prepares an editable instruction; Send remains explicit.
+#[derive(Clone, Copy, Debug)]
+pub struct QuickAction {
+    pub label: &'static str,
+    pub group: &'static str,
+    pub instruction: &'static str,
+}
+
+/// What the bubble shows: the current request, if any, and the agents listening.
+pub struct View<'a> {
+    pub snapshot: Option<&'a Snapshot>,
+    pub agents: &'a [String],
+    pub actions: &'a [QuickAction],
+    pub history: &'a [Turn],
+    pub instruction: &'a str,
+}
+
+const WIDTH: f32 = 360.0;
+/// One height for every control in the bubble, so the rows line up.
+const ROW: f32 = 28.0;
+
+/// The chip just under the selection, on its left edge, where the bubble opens; true when clicked.
+pub fn chip(ctx: &egui::Context, anchor: Rect, tr: Tr) -> bool {
+    egui::Area::new("ask_agent_chip".into())
+        .order(egui::Order::Foreground)
+        .fixed_pos(origin(ctx, anchor, 140.0))
+        .constrain(true)
+        .show(ctx, |ui| {
+            let frame = egui::Frame::popup(ui.style()).corner_radius(14.0).inner_margin(egui::Margin::symmetric(10, 5));
+            let inner = frame.show(ui, |ui| {
+                ui.horizontal(|ui| {
+                    ui.spacing_mut().item_spacing.x = 6.0;
+                    sparkle(ui);
+                    ui.label(RichText::new(tr("Ask Agent")).strong());
+                })
+            });
+            let response = ui.interact(inner.response.rect, ui.id().with("chip"), egui::Sense::click());
+            response.on_hover_cursor(egui::CursorIcon::PointingHand).clicked()
+        })
+        .inner
+}
+
+/// The bubble under `anchor`: instruction, the agent at work, its answer with Keep, Undo and a
+/// follow-up field.
+pub fn bubble(ctx: &egui::Context, anchor: Rect, view: &View, draft: &mut String, picked: &mut Option<String>, tr: Tr) -> Option<Action> {
+    let mut action = None;
+    // True on the first pass the bubble shows after at least one pass without it.
+    let (id, pass) = (egui::Id::new("ask_agent_bubble_pass"), ctx.cumulative_pass_nr());
+    let last = ctx.data(|d| d.get_temp::<u64>(id));
+    ctx.data_mut(|d| d.insert_temp(id, pass));
+    let opened = last.is_none_or(|last| last + 1 < pass);
+    egui::Area::new("ask_agent_bubble".into()).order(egui::Order::Foreground).fixed_pos(origin(ctx, anchor, WIDTH + 40.0)).constrain(true).show(
+        ctx,
+        |ui| {
+            egui::Frame::popup(ui.style()).corner_radius(12.0).inner_margin(egui::Margin::symmetric(16, 14)).show(ui, |ui| {
+                ui.set_width(WIDTH);
+                let spacing = ui.spacing_mut();
+                spacing.item_spacing = vec2(8.0, 10.0);
+                spacing.interact_size.y = ROW;
+                spacing.button_padding = vec2(12.0, 4.0);
+                let status = view.snapshot.map(|s| s.status);
+
+                // Header: who the request is for, then close; the agent choice sits on the right.
+                row(ui, Layout::left_to_right(Align::Center), |ui| {
+                    match view.snapshot.and_then(|s| s.agent.as_deref()) {
+                        Some(name) => agent_label(ui, name),
+                        None => {
+                            sparkle(ui);
+                            ui.label(RichText::new(tr("Ask Agent")).strong());
+                        }
+                    }
+                    ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
+                        if close_button(ui).on_hover_text(tr("Close")).clicked() {
+                            action = Some(Action::Close);
+                        }
+                        if status.is_none() {
+                            picker(ui, view.agents, picked, tr);
+                        }
+                    });
+                });
+
+                if !view.instruction.is_empty() || !view.history.is_empty() {
+                    egui::CollapsingHeader::new(tr("Conversation"))
+                        .id_salt(("ask_conversation", view.history.len()))
+                        .default_open(!view.history.is_empty())
+                        .show(ui, |ui| {
+                            egui::ScrollArea::vertical().id_salt("ask_conversation_scroll").max_height(160.0).show(ui, |ui| {
+                                for turn in view.history {
+                                    ui.label(RichText::new(tr("You")).strong());
+                                    ui.label(&turn.instruction);
+                                    ui.label(RichText::new(tr("Agent")).strong());
+                                    ui.label(&turn.answer);
+                                    ui.separator();
+                                }
+                                if !view.instruction.is_empty() {
+                                    ui.label(RichText::new(tr("Current instruction")).strong());
+                                    ui.label(view.instruction);
+                                }
+                            });
+                        });
+                }
+
+                if let Some(s) = view.snapshot {
+                    for note in &s.notes {
+                        ui.label(RichText::new(note).weak());
+                    }
+                }
+                match status {
+                    Some(Status::Queued) => _ = ui.label(RichText::new(tr("Waiting for an agent…")).weak()),
+                    Some(Status::Working { .. }) => {
+                        row(ui, Layout::left_to_right(Align::Center), |ui| {
+                            ui.spinner();
+                            ui.label(RichText::new(tr("Working on it…")).weak());
+                        });
+                    }
+                    Some(Status::Failed) => {
+                        let error = view.snapshot.and_then(|s| s.error.as_deref()).unwrap_or_default();
+                        ui.label(RichText::new(error).color(ui.visuals().error_fg_color));
+                    }
+                    Some(Status::Answered) => {
+                        ui.label(view.snapshot.and_then(|s| s.answer.as_deref()).unwrap_or_default());
+                        // The decision on the right, primary last, like Send below.
+                        row(ui, Layout::right_to_left(Align::Center), |ui| {
+                            if ui.add(primary(ui, tr("Keep"))).clicked() {
+                                action = Some(Action::Keep);
+                            }
+                            if ui.button(tr("Undo")).on_hover_text(tr("Undo all of the agent's edits")).clicked() {
+                                action = Some(Action::Undo);
+                            }
+                        });
+                        ui.separator();
+                    }
+                    None => {}
+                }
+                if matches!(status, None | Some(Status::Answered | Status::Failed)) {
+                    let hint = if status.is_none() { tr("What should the agent do?") } else { tr("Reply to refine…") };
+                    let field = ui.add(
+                        egui::TextEdit::multiline(draft)
+                            .hint_text(hint)
+                            .desired_rows(3)
+                            .desired_width(f32::INFINITY)
+                            .margin(egui::Margin::symmetric(8, 6))
+                            // Shift+Enter makes a new line; Enter alone sends, below.
+                            .return_key(egui::KeyboardShortcut::new(egui::Modifiers::SHIFT, Key::Enter)),
+                    );
+                    // Focus the field once, when the bubble opens, so Tab can then reach the other controls.
+                    if status.is_none() && opened {
+                        field.request_focus();
+                    }
+                    let enter = field.has_focus() && ui.input(|i| i.key_pressed(Key::Enter) && !i.modifiers.shift);
+                    // Footer: starting points on the left, Send on the right.
+                    row(ui, Layout::left_to_right(Align::Center), |ui| {
+                        if matches!(status, None | Some(Status::Answered)) && !view.actions.is_empty() {
+                            quick_actions(ui, view.actions, draft, tr);
+                        }
+                        ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
+                            let ready = !draft.trim().is_empty();
+                            if (ui.add_enabled(ready, primary(ui, tr("Send"))).clicked() || enter) && ready {
+                                action = Some(Action::Send(std::mem::take(draft).trim().to_string()));
+                            }
+                        });
+                    });
+                }
+            });
+        },
+    );
+    action
+}
+
+/// Where the chip and the bubble open: under the selection, on its left edge, kept on screen.
+fn origin(ctx: &egui::Context, anchor: Rect, width: f32) -> egui::Pos2 {
+    let screen = ctx.content_rect();
+    pos2(anchor.left().clamp(8.0, (screen.right() - width).max(8.0)), anchor.bottom() + 8.0)
+}
+
+/// One row of controls, exactly `ROW` high, whatever the layout direction.
+fn row(ui: &mut egui::Ui, layout: Layout, add: impl FnOnce(&mut egui::Ui)) {
+    ui.allocate_ui_with_layout(vec2(ui.available_width(), ROW), layout, add);
+}
+
+/// The one filled button of a row.
+fn primary(ui: &egui::Ui, text: String) -> egui::Button<'static> {
+    egui::Button::new(RichText::new(text).strong()).fill(ui.visuals().selection.bg_fill).min_size(vec2(72.0, ROW))
+}
+
+/// Native menus keep less common actions out of the writing surface.
+fn quick_actions(ui: &mut egui::Ui, actions: &[QuickAction], draft: &mut String, tr: Tr) {
+    ui.menu_button(tr("Actions"), |ui| {
+        for (index, action) in actions.iter().enumerate() {
+            if action.group.is_empty() {
+                if ui.button(tr(action.label)).clicked() {
+                    *draft = action.instruction.into();
+                    ui.close();
+                }
+            } else if !actions[..index].iter().any(|previous| previous.group == action.group) {
+                ui.menu_button(tr(action.group), |ui| {
+                    for action in actions.iter().filter(|item| item.group == action.group) {
+                        if ui.button(tr(action.label)).clicked() {
+                            *draft = action.instruction.into();
+                            ui.close();
+                        }
+                    }
+                });
+            }
+        }
+    })
+    .response
+    .on_hover_text(tr("Choose a starting instruction, then edit and send"));
+}
+
+/// Who the request goes to, when more than one agent listens. Called in a right-to-left row, so
+/// the box comes first and its label lands on its left.
+fn picker(ui: &mut egui::Ui, agents: &[String], picked: &mut Option<String>, tr: Tr) {
+    if agents.len() < 2 {
+        return;
+    }
+    egui::ComboBox::from_id_salt("ask_agent_picker").selected_text(picked.clone().unwrap_or_else(|| tr("Any"))).show_ui(ui, |ui| {
+        ui.selectable_value(picked, None, tr("Any"));
+        for name in agents {
+            ui.selectable_value(picked, Some(name.clone()), name);
+        }
+    });
+    ui.label(RichText::new(tr("Agent")).weak());
+}
+
+/// The agent's colour dot and name.
+pub fn agent_label(ui: &mut egui::Ui, name: &str) {
+    let [r, g, b] = colour(name);
+    let (dot, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover());
+    ui.painter().circle(dot.center(), 5.0, Color32::from_rgb(r, g, b), Stroke::NONE);
+    ui.label(RichText::new(name).strong());
+}
+
+/// A drawn four-point sparkle in the selection colour, so it shows whatever the interface font.
+fn sparkle(ui: &mut egui::Ui) {
+    let (rect, _) = ui.allocate_exact_size(vec2(12.0, 12.0), egui::Sense::hover());
+    let (c, colour) = (rect.center(), accent(ui.visuals()));
+    for (long, short) in [(vec2(0.0, 6.0), vec2(1.6, 0.0)), (vec2(6.0, 0.0), vec2(0.0, 1.6))] {
+        let points = vec![c - long, c + short, c + long, c - short];
+        ui.painter().add(egui::Shape::convex_polygon(points, colour, Stroke::NONE));
+    }
+}
+
+/// Of the selection's fill and text colours, the one that stands out more on the bubble.
+fn accent(v: &egui::Visuals) -> Color32 {
+    let luma = |c: Color32| 0.299 * f32::from(c.r()) + 0.587 * f32::from(c.g()) + 0.114 * f32::from(c.b());
+    let (fill, text, bg) = (v.selection.bg_fill, v.selection.stroke.color, luma(v.window_fill));
+    if (luma(fill) - bg).abs() > (luma(text) - bg).abs() { fill } else { text }
+}
+
+/// A drawn ✕, so it shows whatever the interface font.
+fn close_button(ui: &mut egui::Ui) -> egui::Response {
+    let (rect, response) = ui.allocate_exact_size(vec2(20.0, 20.0), egui::Sense::click());
+    let colour = if response.hovered() { ui.visuals().strong_text_color() } else { ui.visuals().weak_text_color() };
+    let r = rect.shrink(5.0);
+    for (a, b) in [(r.left_top(), r.right_bottom()), (r.right_top(), r.left_bottom())] {
+        ui.painter().line_segment([a, b], Stroke::new(1.5, colour));
+    }
+    response
+}
