ACPOptions.OnPermission
Go · package github.com/muthuishere/toolnexus/golang · SPEC §8 · golang/acp.go
type ACPOptions struct { Command string Args []string // ... OnPermission func(ctx context.Context, params json.RawMessage) (optionID string, err error) OnUpdate func(json.RawMessage)}
func (c *ACPClient) Generate(req InProcessRequest) (InProcessResponse, error)func (c *ACPClient) GenerateCtx(ctx context.Context, req InProcessRequest) (InProcessResponse, error)func (c *ACPClient) GenerateStream(ctx context.Context, req InProcessRequest, emit func(StreamEvent)) (InProcessResponse, error)
// InProcessOptions.GenerateStream — pass ACPClient.GenerateStream to make Client.Stream work.Four opt-in hooks on an ACP model source (LoadACP). 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.
OnPermission— called for everysession/request_permissionwith its raw params (sessionId,toolCall,options); toolnexus answers with the returnedoptionId. An error or an empty id answers the first reject-kind option, elsecancelled. It runs off the read loop, so it may block on a human — butPermissionTimeoutstill bounds the whole turn.OnUpdate— 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.GenerateCtx— cancelling its context sends thesession/cancelnotification, waits for the turn’s reply and returns the cancellation error. The process and session stay warm.GenerateStream— passed asInProcessOptions.GenerateStream,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 OnPermission
Section titled “1. The smallest useful call — a plan mode with OnPermission”package main
import ( "bufio" "context" "encoding/json" "fmt" "log" "os"
toolnexus "github.com/muthuishere/toolnexus/golang")
// fakeAgent is 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.func fakeAgent() { in := bufio.NewScanner(os.Stdin) in.Buffer(make([]byte, 0, 1<<20), 1<<24) send := func(v map[string]any) { b, _ := json.Marshal(v); os.Stdout.Write(append(b, '\n')) } update := func(u map[string]any) { send(map[string]any{"jsonrpc": "2.0", "method": "session/update", "params": map[string]any{"sessionId": "s1", "update": u}}) } for in.Scan() { var m struct { ID json.RawMessage `json:"id"` Method string `json:"method"` } if json.Unmarshal(in.Bytes(), &m) != nil { continue } switch m.Method { case "initialize": send(map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": map[string]any{"protocolVersion": 1}}) case "session/new": send(map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": map[string]any{"sessionId": "s1"}}) case "session/prompt": update(map[string]any{"sessionUpdate": "agent_thought_chunk", "content": map[string]any{"type": "text", "text": "thinking"}}) update(map[string]any{"sessionUpdate": "tool_call", "toolCallId": "t1", "title": "ls", "rawInput": map[string]any{"path": "."}}) send(map[string]any{"jsonrpc": "2.0", "id": "p1", "method": "session/request_permission", "params": map[string]any{ "sessionId": "s1", "toolCall": map[string]any{"toolCallId": "t1", "title": "ls"}, "options": []any{ map[string]any{"optionId": "allow", "kind": "allow_once", "name": "Allow"}, map[string]any{"optionId": "reject", "kind": "reject_once", "name": "Reject"}, }, }}) in.Scan() // the host's answer var ans struct { Result struct { Outcome struct { OptionID string `json:"optionId"` } `json:"outcome"` } `json:"result"` } _ = json.Unmarshal(in.Bytes(), &ans) update(map[string]any{"sessionUpdate": "tool_call_update", "toolCallId": "t1", "status": "completed", "content": []any{map[string]any{"type": "content", "content": map[string]any{"type": "text", "text": "a.txt"}}}}) update(map[string]any{"sessionUpdate": "agent_message_chunk", "content": map[string]any{"type": "text", "text": "picked " + ans.Result.Outcome.OptionID}}) send(map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": map[string]any{"stopReason": "end_turn"}}) } }}
func agentOptions() toolnexus.ACPOptions { self, err := os.Executable() if err != nil { log.Fatal(err) } return toolnexus.ACPOptions{Command: self, Env: []string{"FAKE_ACP_AGENT=1"}}}
func main() { if os.Getenv("FAKE_ACP_AGENT") == "1" { fakeAgent() return } opts := agentOptions() var asked string opts.OnPermission = func(_ context.Context, params json.RawMessage) (string, error) { var p struct { ToolCall struct { Title string `json:"title"` } `json:"toolCall"` } _ = json.Unmarshal(params, &p) asked = p.ToolCall.Title return "reject", nil // a plan mode: never let the agent act } c, err := toolnexus.LoadACP(context.Background(), opts) if err != nil { log.Fatal(err) } defer c.Close()
resp, err := c.Generate(toolnexus.InProcessRequest{Messages: []any{map[string]any{"role": "user", "content": "list files"}}}) if err != nil { log.Fatal(err) } if asked != "ls" || resp.Content != "picked reject" { log.Fatalf("asked=%q content=%q", asked, resp.Content) } fmt.Println("ok:", resp.Content)}2. The realistic case — a live transcript with OnUpdate
Section titled “2. The realistic case — a live transcript with OnUpdate”package main
import ( "bufio" "context" "encoding/json" "fmt" "log" "os" "strings" "sync"
toolnexus "github.com/muthuishere/toolnexus/golang")
// fakeAgent is 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.func fakeAgent() { in := bufio.NewScanner(os.Stdin) in.Buffer(make([]byte, 0, 1<<20), 1<<24) send := func(v map[string]any) { b, _ := json.Marshal(v); os.Stdout.Write(append(b, '\n')) } update := func(u map[string]any) { send(map[string]any{"jsonrpc": "2.0", "method": "session/update", "params": map[string]any{"sessionId": "s1", "update": u}}) } for in.Scan() { var m struct { ID json.RawMessage `json:"id"` Method string `json:"method"` } if json.Unmarshal(in.Bytes(), &m) != nil { continue } switch m.Method { case "initialize": send(map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": map[string]any{"protocolVersion": 1}}) case "session/new": send(map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": map[string]any{"sessionId": "s1"}}) case "session/prompt": update(map[string]any{"sessionUpdate": "agent_thought_chunk", "content": map[string]any{"type": "text", "text": "thinking"}}) update(map[string]any{"sessionUpdate": "tool_call", "toolCallId": "t1", "title": "ls", "rawInput": map[string]any{"path": "."}}) send(map[string]any{"jsonrpc": "2.0", "id": "p1", "method": "session/request_permission", "params": map[string]any{ "sessionId": "s1", "toolCall": map[string]any{"toolCallId": "t1", "title": "ls"}, "options": []any{ map[string]any{"optionId": "allow", "kind": "allow_once", "name": "Allow"}, map[string]any{"optionId": "reject", "kind": "reject_once", "name": "Reject"}, }, }}) in.Scan() // the host's answer var ans struct { Result struct { Outcome struct { OptionID string `json:"optionId"` } `json:"outcome"` } `json:"result"` } _ = json.Unmarshal(in.Bytes(), &ans) update(map[string]any{"sessionUpdate": "tool_call_update", "toolCallId": "t1", "status": "completed", "content": []any{map[string]any{"type": "content", "content": map[string]any{"type": "text", "text": "a.txt"}}}}) update(map[string]any{"sessionUpdate": "agent_message_chunk", "content": map[string]any{"type": "text", "text": "picked " + ans.Result.Outcome.OptionID}}) send(map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": map[string]any{"stopReason": "end_turn"}}) } }}
func agentOptions() toolnexus.ACPOptions { self, err := os.Executable() if err != nil { log.Fatal(err) } return toolnexus.ACPOptions{Command: self, Env: []string{"FAKE_ACP_AGENT=1"}}}
func main() { if os.Getenv("FAKE_ACP_AGENT") == "1" { fakeAgent() return } opts := agentOptions() var mu sync.Mutex var kinds []string opts.OnUpdate = func(raw json.RawMessage) { // runs on the read loop: never block here var u struct { Update struct { SessionUpdate string `json:"sessionUpdate"` } `json:"update"` } _ = json.Unmarshal(raw, &u) mu.Lock() kinds = append(kinds, u.Update.SessionUpdate) mu.Unlock() } c, err := toolnexus.LoadACP(context.Background(), opts) if err != nil { log.Fatal(err) } defer c.Close()
// GenerateCtx is Generate with a context: cancelling it sends session/cancel. resp, err := c.GenerateCtx(context.Background(), toolnexus.InProcessRequest{Messages: []any{map[string]any{"role": "user", "content": "list files"}}}) if err != nil { log.Fatal(err) } mu.Lock() got := strings.Join(kinds, ",") mu.Unlock() if got != "agent_thought_chunk,tool_call,tool_call_update,agent_message_chunk" { log.Fatalf("updates seen: %s", got) } // Without OnPermission the first allow option is picked, as before. if resp.Content != "picked allow" { log.Fatalf("content = %q (thoughts must never reach it)", resp.Content) } fmt.Println("ok:", got)}3. The full surface — Client.Stream over ACP with GenerateStream
Section titled “3. The full surface — Client.Stream over ACP with GenerateStream”package main
import ( "bufio" "context" "encoding/json" "fmt" "log" "os" "strings"
toolnexus "github.com/muthuishere/toolnexus/golang")
// fakeAgent is 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.func fakeAgent() { in := bufio.NewScanner(os.Stdin) in.Buffer(make([]byte, 0, 1<<20), 1<<24) send := func(v map[string]any) { b, _ := json.Marshal(v); os.Stdout.Write(append(b, '\n')) } update := func(u map[string]any) { send(map[string]any{"jsonrpc": "2.0", "method": "session/update", "params": map[string]any{"sessionId": "s1", "update": u}}) } for in.Scan() { var m struct { ID json.RawMessage `json:"id"` Method string `json:"method"` } if json.Unmarshal(in.Bytes(), &m) != nil { continue } switch m.Method { case "initialize": send(map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": map[string]any{"protocolVersion": 1}}) case "session/new": send(map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": map[string]any{"sessionId": "s1"}}) case "session/prompt": update(map[string]any{"sessionUpdate": "agent_thought_chunk", "content": map[string]any{"type": "text", "text": "thinking"}}) update(map[string]any{"sessionUpdate": "tool_call", "toolCallId": "t1", "title": "ls", "rawInput": map[string]any{"path": "."}}) send(map[string]any{"jsonrpc": "2.0", "id": "p1", "method": "session/request_permission", "params": map[string]any{ "sessionId": "s1", "toolCall": map[string]any{"toolCallId": "t1", "title": "ls"}, "options": []any{ map[string]any{"optionId": "allow", "kind": "allow_once", "name": "Allow"}, map[string]any{"optionId": "reject", "kind": "reject_once", "name": "Reject"}, }, }}) in.Scan() // the host's answer var ans struct { Result struct { Outcome struct { OptionID string `json:"optionId"` } `json:"outcome"` } `json:"result"` } _ = json.Unmarshal(in.Bytes(), &ans) update(map[string]any{"sessionUpdate": "tool_call_update", "toolCallId": "t1", "status": "completed", "content": []any{map[string]any{"type": "content", "content": map[string]any{"type": "text", "text": "a.txt"}}}}) update(map[string]any{"sessionUpdate": "agent_message_chunk", "content": map[string]any{"type": "text", "text": "picked " + ans.Result.Outcome.OptionID}}) send(map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": map[string]any{"stopReason": "end_turn"}}) } }}
func agentOptions() toolnexus.ACPOptions { self, err := os.Executable() if err != nil { log.Fatal(err) } return toolnexus.ACPOptions{Command: self, Env: []string{"FAKE_ACP_AGENT=1"}}}
func main() { if os.Getenv("FAKE_ACP_AGENT") == "1" { fakeAgent() return } acp, err := toolnexus.LoadACP(context.Background(), agentOptions()) if err != nil { log.Fatal(err) } defer acp.Close()
client := toolnexus.CreateInProcessClient(toolnexus.InProcessOptions{ Model: "fake-acp", GenerateStream: acp.GenerateStream, // makes Client.Stream work }) ch, err := client.Stream(context.Background(), "list files", nil) if err != nil { log.Fatal(err) } var text strings.Builder var tools []string for ev := range ch { switch ev.Type { case "text": text.WriteString(ev.Delta) case "tool_call", "tool_result": // The agent ran this itself: Via "acp", never executed by the loop. tools = append(tools, ev.Type+":"+ev.Name+":"+ev.Via) case "error": log.Fatal(ev.Err) } } if text.String() != "picked allow" || strings.Join(tools, ",") != "tool_call:ls:acp,tool_result:ls:acp" { log.Fatalf("text=%q tools=%v", text.String(), tools) } fmt.Println("ok:", text.String(), tools)}See also
Section titled “See also”CreateClient— 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.