Skip to content

ACP host hooks (Go, Rust)

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.

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.

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 here
let 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.

  • Only Go and Rust have the hooks; the other six ports are pending (openspec/changes/add-go-host-asks).
  • In Go, PermissionTimeout still bounds the whole turn, so a slow human approval can time it out. Raise it when OnPermission waits on a person.

See the Go API reference for the full field list, and Local & in-process models for CreateInProcessClient.