ACP host hooks (Go, Rust)
The four hooks
Section titled “The four hooks”| hook | what it does | unset |
|---|---|---|
ACPOptions.OnPermission(ctx, params) (optionID, error) |
you pick the option for every session/request_permission; an error or empty id picks the first reject option, else cancels |
first allow option, as before |
ACPOptions.OnUpdate(params) |
the raw params of every session/update (message and thought chunks, tool calls, plans), before filtering. Runs on the read loop — must not block |
only message text is used |
ACPClient.GenerateCtx(ctx, req) |
cancelling ctx sends session/cancel; the process and session stay warm |
Generate cannot be cancelled |
ACPClient.GenerateStream, passed as InProcessOptions.GenerateStream |
makes Client.Stream work: message chunks become text deltas, the agent’s own tool calls arrive as tool_call / tool_result events with Via: "acp" |
a client built on ACP refuses to stream |
With no hook set, behaviour is byte-identical to 0.21.
Ask a human before the agent writes
Section titled “Ask a human before the agent writes”acp, err := toolnexus.LoadACP(ctx, toolnexus.ACPOptions{ Command: "my-coding-agent", Args: []string{"acp"}, PermissionTimeout: 5 * time.Minute, // bounds the whole turn, so leave room for a human OnPermission: func(ctx context.Context, params json.RawMessage) (string, error) { var req struct { ToolCall struct{ Title string `json:"title"` } `json:"toolCall"` Options []struct { OptionID string `json:"optionId"` Kind string `json:"kind"` // allow_once, allow_always, reject_once, ... } `json:"options"` } if err := json.Unmarshal(params, &req); err != nil { return "", err // -> first reject option } if !askUser(ctx, "Allow: "+req.ToolCall.Title+"?") { return "", nil // empty -> first reject option } for _, o := range req.Options { if o.Kind == "allow_once" { return o.OptionID, nil } } return "", nil }, OnUpdate: func(params json.RawMessage) { ui.Push(params) }, // never block here})if err != nil { return err}defer acp.Close()Stream and cancel a turn through the ordinary client
Section titled “Stream and cancel a turn through the ordinary client”client := toolnexus.CreateInProcessClient(toolnexus.InProcessOptions{ Model: "acp", GenerateStream: acp.GenerateStream, // Stream works; Run uses it too})
ctx, cancel := context.WithCancel(ctx)defer cancel() // cancelling mid-turn sends session/cancel; the session stays warm
events, err := client.Stream(ctx, "fix the failing test", tk)if err != nil { return err}for ev := range events { switch ev.Type { case "text": fmt.Print(ev.Delta) case "tool_call", "tool_result": // ev.Via == "acp": the agent already ran this tool; the loop never executes it fmt.Println("\n[agent]", ev.Type, ev.Name) }}Without streaming, pass Generate and call acp.GenerateCtx yourself when you need a
cancellable turn.
In Rust
Section titled “In Rust”The same four hooks, in the Rust port’s shape: AcpOptions::on_permission takes an async
handler built with permission_handler, on_update sees the raw params of every
session/update, agent.cancel() (or a cancelled token passed to generate_cancellable)
sends session/cancel, and acp_generate_stream makes Client::stream work.
use std::sync::Arc;use toolnexus::acp::{AcpClient, AcpOptions, PermissionOutcome, permission_handler};use toolnexus::client::{InProcessOptions, acp_generate, acp_generate_stream, create_in_process_client};
let opts = AcpOptions::new("my-coding-agent") .args(["acp"]) .on_permission(permission_handler(|req| async move { // show req.tool_call and req.options to a human; answer one option id match req.first_allow() { Some(o) if ask_user(&req).await => PermissionOutcome::Selected(o.option_id.clone()), _ => PermissionOutcome::Cancelled, } })) .on_update(|params| ui_push(params)); // never block herelet agent = Arc::new(AcpClient::load(opts).await?);
let client = create_in_process_client(InProcessOptions { model: "acp".into(), generate: Some(acp_generate(agent.clone())), generate_stream: Some(acp_generate_stream(agent.clone())), // Stream works ..Default::default()});let mut events = client.stream("fix the failing test", &tk)?;while let Some(ev) = events.recv().await { match ev.kind.as_str() { "text" => print!("{}", ev.delta), // ev.via == "acp": the agent already ran this tool; the loop never executes it "tool_call" | "tool_result" => println!("\n[agent] {} {}", ev.kind, ev.name), _ => {} }}agent.close().await;One difference from Go: Rust’s permission_timeout (30 s default) bounds each
on_permission call, not the whole turn. A handler slower than that is answered cancelled
and the turn fails with AcpError::PermissionTimeout.
What is not done
Section titled “What is not done”- Only Go and Rust have the hooks; the other six ports are pending
(
openspec/changes/add-go-host-asks). - In Go,
PermissionTimeoutstill bounds the whole turn, so a slow human approval can time it out. Raise it whenOnPermissionwaits on a person.
See the Go API reference for the full field list, and
Local & in-process models for CreateInProcessClient.