AcpOptions.on_permission
Rust · crate toolnexus · SPEC §8
pub struct AcpOptions { pub command: String, pub args: Vec<String>, // ... pub permission_timeout: Duration, // default 30s pub on_permission: Option<PermissionHandler>, // async: PermissionRequest -> PermissionOutcome pub on_update: Option<UpdateHandler>, // Arc<dyn Fn(&Value)>: raw session/update params}pub fn permission_handler<F, Fut>(f: F) -> PermissionHandler; // wrap an async closurepub enum PermissionOutcome { Selected(String), Cancelled, Reject }
impl AcpClient { pub async fn load(opts: AcpOptions) -> Result<Self, AcpError>; pub async fn generate(&self, messages: &[(String, String)]) -> Result<String, AcpError>; pub async fn generate_cancellable(&self, messages: &[(String, String)], cancel: &CancellationToken) -> Result<String, AcpError>; pub async fn generate_stream<F: FnMut(AcpStreamEvent)>(&self, messages: &[(String, String)], cancel: &CancellationToken, emit: F) -> Result<String, AcpError>;}
// toolnexus::client::{acp_generate, acp_generate_stream}(Arc<AcpClient>) — the in-process seams:// pass acp_generate_stream as InProcessOptions.generate_stream to make Client::stream work.Four opt-in hooks on an ACP model source (AcpClient::load). With none set, behaviour is
byte-identical to before: a permission request gets the first allow-kind option and only
agent_message_chunk text forms the reply.
on_permission— called for everysession/request_permissionwith aPermissionRequest(session_id, rawtool_call,options); toolnexus answers with the returned outcome.PermissionOutcome::Reject(or an emptySelectedid) answers the first reject-kind option, elsecancelled. It is async and may wait on a human — butpermission_timeoutstill bounds it.on_update— the raw params of everysession/update(message and thought chunks, tool calls and their updates, plans), before filtering. It runs on the read loop: it must not block.generate_cancellable— firing its token sends thesession/cancelnotification, waits for the turn’s end and returnsAcpError::Cancelled. The process and session stay warm.generate_stream— wrapped byacp_generate_streamand passed asInProcessOptions.generate_stream,Client::streamstreams instead of refusing: message chunks become text deltas, and the agent’s own tool calls arrive astool_call/tool_resultevents withvia: "acp". The loop never executes them.
When to use it
Section titled “When to use it”Building a UI over a coding agent (devin, opencode, …) driven through ACP: a plan mode that rejects every write, a human approval prompt, a live transcript of the agent’s thoughts and tool calls, or a stop button that cancels the turn without killing the warm session.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”Each example runs a tiny fake ACP agent — the snippet re-runs its own binary with
FAKE_ACP_AGENT=1 — so no real agent is spawned and nothing touches the network.
1. The smallest useful call — a plan mode with on_permission
Section titled “1. The smallest useful call — a plan mode with on_permission”use std::sync::{Arc, Mutex};
use serde_json::{Value, json};use toolnexus::acp::{AcpClient, AcpOptions, PermissionOutcome, PermissionRequest, permission_handler};
/// A tiny ACP agent: this same binary, re-run with FAKE_ACP_AGENT=1. Each turn it/// narrates (thought + tool_call), asks one permission, then reports the tool's/// result and replies with the option the host picked.fn fake_agent() { use std::io::{BufRead, Write}; let send = |v: Value| { let mut out = std::io::stdout().lock(); let _ = writeln!(out, "{v}"); let _ = out.flush(); }; let update = |u: Value| send(json!({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "s1", "update": u}})); let mut lines = std::io::stdin().lock().lines(); while let Some(Ok(line)) = lines.next() { let Ok(m) = serde_json::from_str::<Value>(&line) else { continue }; let id = m["id"].clone(); match m["method"].as_str().unwrap_or("") { "initialize" => send(json!({"jsonrpc": "2.0", "id": id, "result": {"protocolVersion": 1}})), "session/new" => send(json!({"jsonrpc": "2.0", "id": id, "result": {"sessionId": "s1"}})), "session/prompt" => { update(json!({"sessionUpdate": "agent_thought_chunk", "content": {"type": "text", "text": "thinking"}})); update(json!({"sessionUpdate": "tool_call", "toolCallId": "t1", "title": "ls", "rawInput": {"path": "."}})); send(json!({"jsonrpc": "2.0", "id": "p1", "method": "session/request_permission", "params": { "sessionId": "s1", "toolCall": {"toolCallId": "t1", "title": "ls"}, "options": [ {"optionId": "allow", "kind": "allow_once", "name": "Allow"}, {"optionId": "reject", "kind": "reject_once", "name": "Reject"}, ], }})); let answer: Value = lines.next().and_then(Result::ok).and_then(|l| serde_json::from_str(&l).ok()).unwrap_or_default(); let picked = answer["result"]["outcome"]["optionId"].as_str().unwrap_or("cancelled").to_owned(); update(json!({"sessionUpdate": "tool_call_update", "toolCallId": "t1", "status": "completed", "content": [{"type": "content", "content": {"type": "text", "text": "a.txt"}}]})); update(json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": format!("picked {picked}")}})); send(json!({"jsonrpc": "2.0", "id": id, "result": {"stopReason": "end_turn"}})); } _ => {} } }}
fn agent_options() -> AcpOptions { let me = std::env::current_exe().expect("own binary").to_string_lossy().into_owned(); AcpOptions::new(me).env("FAKE_ACP_AGENT", "1")}
#[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { if std::env::var("FAKE_ACP_AGENT").as_deref() == Ok("1") { fake_agent(); return Ok(()); } let asked = Arc::new(Mutex::new(String::new())); let record = asked.clone(); let opts = agent_options().on_permission(permission_handler(move |req: PermissionRequest| { *record.lock().unwrap() = req.tool_call["title"].as_str().unwrap_or("").to_owned(); async { PermissionOutcome::Selected("reject".into()) } // a plan mode: never let the agent act })); let acp = AcpClient::load(opts).await?;
let text = acp.generate(&[("user".into(), "list files".into())]).await?; acp.close().await; assert_eq!(*asked.lock().unwrap(), "ls"); assert_eq!(text, "picked reject"); println!("ok: {text}"); Ok(())}2. The realistic case — a live transcript with on_update
Section titled “2. The realistic case — a live transcript with on_update”use std::sync::{Arc, Mutex};
use serde_json::{Value, json};use tokio_util::sync::CancellationToken;use toolnexus::acp::{AcpClient, AcpOptions};
/// A tiny ACP agent: this same binary, re-run with FAKE_ACP_AGENT=1. Each turn it/// narrates (thought + tool_call), asks one permission, then reports the tool's/// result and replies with the option the host picked.fn fake_agent() { use std::io::{BufRead, Write}; let send = |v: Value| { let mut out = std::io::stdout().lock(); let _ = writeln!(out, "{v}"); let _ = out.flush(); }; let update = |u: Value| send(json!({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "s1", "update": u}})); let mut lines = std::io::stdin().lock().lines(); while let Some(Ok(line)) = lines.next() { let Ok(m) = serde_json::from_str::<Value>(&line) else { continue }; let id = m["id"].clone(); match m["method"].as_str().unwrap_or("") { "initialize" => send(json!({"jsonrpc": "2.0", "id": id, "result": {"protocolVersion": 1}})), "session/new" => send(json!({"jsonrpc": "2.0", "id": id, "result": {"sessionId": "s1"}})), "session/prompt" => { update(json!({"sessionUpdate": "agent_thought_chunk", "content": {"type": "text", "text": "thinking"}})); update(json!({"sessionUpdate": "tool_call", "toolCallId": "t1", "title": "ls", "rawInput": {"path": "."}})); send(json!({"jsonrpc": "2.0", "id": "p1", "method": "session/request_permission", "params": { "sessionId": "s1", "toolCall": {"toolCallId": "t1", "title": "ls"}, "options": [ {"optionId": "allow", "kind": "allow_once", "name": "Allow"}, {"optionId": "reject", "kind": "reject_once", "name": "Reject"}, ], }})); let answer: Value = lines.next().and_then(Result::ok).and_then(|l| serde_json::from_str(&l).ok()).unwrap_or_default(); let picked = answer["result"]["outcome"]["optionId"].as_str().unwrap_or("cancelled").to_owned(); update(json!({"sessionUpdate": "tool_call_update", "toolCallId": "t1", "status": "completed", "content": [{"type": "content", "content": {"type": "text", "text": "a.txt"}}]})); update(json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": format!("picked {picked}")}})); send(json!({"jsonrpc": "2.0", "id": id, "result": {"stopReason": "end_turn"}})); } _ => {} } }}
fn agent_options() -> AcpOptions { let me = std::env::current_exe().expect("own binary").to_string_lossy().into_owned(); AcpOptions::new(me).env("FAKE_ACP_AGENT", "1")}
#[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { if std::env::var("FAKE_ACP_AGENT").as_deref() == Ok("1") { fake_agent(); return Ok(()); } let kinds = Arc::new(Mutex::new(Vec::<String>::new())); let sink = kinds.clone(); // Runs on the read loop: never block here. let opts = agent_options().on_update(move |params: &Value| { sink.lock().unwrap().push(params["update"]["sessionUpdate"].as_str().unwrap_or("").to_owned()); }); let acp = AcpClient::load(opts).await?;
// generate_cancellable is generate bound to a token: firing it sends session/cancel. let token = CancellationToken::new(); let text = acp.generate_cancellable(&[("user".into(), "list files".into())], &token).await?; acp.close().await;
let got = kinds.lock().unwrap().join(","); assert_eq!(got, "agent_thought_chunk,tool_call,tool_call_update,agent_message_chunk"); // Without on_permission the first allow option is picked, as before. assert_eq!(text, "picked allow", "thoughts must never reach the reply"); println!("ok: {got}"); Ok(())}3. The full surface — Client::stream over ACP with acp_generate_stream
Section titled “3. The full surface — Client::stream over ACP with acp_generate_stream”use std::sync::Arc;
use serde_json::{Value, json};use toolnexus::acp::{AcpClient, AcpOptions};use toolnexus::client::{InProcessOptions, acp_generate_stream, create_in_process_client};use toolnexus::{BuiltinsConfig, ToolkitOptions, create_toolkit};
/// A tiny ACP agent: this same binary, re-run with FAKE_ACP_AGENT=1. Each turn it/// narrates (thought + tool_call), asks one permission, then reports the tool's/// result and replies with the option the host picked.fn fake_agent() { use std::io::{BufRead, Write}; let send = |v: Value| { let mut out = std::io::stdout().lock(); let _ = writeln!(out, "{v}"); let _ = out.flush(); }; let update = |u: Value| send(json!({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "s1", "update": u}})); let mut lines = std::io::stdin().lock().lines(); while let Some(Ok(line)) = lines.next() { let Ok(m) = serde_json::from_str::<Value>(&line) else { continue }; let id = m["id"].clone(); match m["method"].as_str().unwrap_or("") { "initialize" => send(json!({"jsonrpc": "2.0", "id": id, "result": {"protocolVersion": 1}})), "session/new" => send(json!({"jsonrpc": "2.0", "id": id, "result": {"sessionId": "s1"}})), "session/prompt" => { update(json!({"sessionUpdate": "agent_thought_chunk", "content": {"type": "text", "text": "thinking"}})); update(json!({"sessionUpdate": "tool_call", "toolCallId": "t1", "title": "ls", "rawInput": {"path": "."}})); send(json!({"jsonrpc": "2.0", "id": "p1", "method": "session/request_permission", "params": { "sessionId": "s1", "toolCall": {"toolCallId": "t1", "title": "ls"}, "options": [ {"optionId": "allow", "kind": "allow_once", "name": "Allow"}, {"optionId": "reject", "kind": "reject_once", "name": "Reject"}, ], }})); let answer: Value = lines.next().and_then(Result::ok).and_then(|l| serde_json::from_str(&l).ok()).unwrap_or_default(); let picked = answer["result"]["outcome"]["optionId"].as_str().unwrap_or("cancelled").to_owned(); update(json!({"sessionUpdate": "tool_call_update", "toolCallId": "t1", "status": "completed", "content": [{"type": "content", "content": {"type": "text", "text": "a.txt"}}]})); update(json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": format!("picked {picked}")}})); send(json!({"jsonrpc": "2.0", "id": id, "result": {"stopReason": "end_turn"}})); } _ => {} } }}
fn agent_options() -> AcpOptions { let me = std::env::current_exe().expect("own binary").to_string_lossy().into_owned(); AcpOptions::new(me).env("FAKE_ACP_AGENT", "1")}
#[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { if std::env::var("FAKE_ACP_AGENT").as_deref() == Ok("1") { fake_agent(); return Ok(()); } let acp = Arc::new(AcpClient::load(agent_options()).await?); let client = create_in_process_client(InProcessOptions { model: "fake-acp".into(), generate_stream: Some(acp_generate_stream(acp.clone())), // makes Client::stream work ..Default::default() }); let tk = create_toolkit(ToolkitOptions { builtins: Some(BuiltinsConfig::off()), ..Default::default() }).await?;
let mut rx = client.stream("list files", &tk)?; let (mut text, mut tools) = (String::new(), Vec::new()); while let Some(ev) = rx.recv().await { match ev.kind.as_str() { "text" => text.push_str(&ev.delta), // The agent ran this itself: via "acp", never executed by the loop. "tool_call" | "tool_result" => tools.push(format!("{}:{}:{}", ev.kind, ev.name, ev.via)), "error" => panic!("stream error: {:?}", ev.error), _ => {} } } acp.close().await; assert_eq!(text, "picked allow"); assert_eq!(tools, ["tool_call:ls:acp", "tool_result:ls:acp"]); println!("ok: {text} {tools:?}"); Ok(())}See also
Section titled “See also”create_client— The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory.Client.stream— The streaming loop: text deltas, tool-call events, and suspension events as they happen.Hooks— Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite.