# 18 - ACP Provider (Agent Client Protocol)
The ACP provider enables GoClaw to orchestrate external coding agents (Claude Code, Codex CLI, Gemini CLI, Kiro, or any ACP-compatible agent) as subprocesses via JSON-RPC 2.0 over stdio. One provider covers all ACP agents through config-driven agent registry.
> **References:** [ACP Spec](https://agentclientprotocol.com/) · [ACP Schema](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/schema/schema.json) · Issue [#189](https://github.com/nextlevelbuilder/goclaw/issues/189) · PR [#190](https://github.com/nextlevelbuilder/goclaw/pull/190)
---
## 1. Architecture
```mermaid
flowchart TD
AL["GoClaw Agent Loop"] -->|"Chat / ChatStream"| ACP["ACPProvider
(acp_provider.go)"]
ACP -->|"GetOrSpawn"| PP["ProcessPool
(process.go)"]
PP -->|"spawn binary"| PROC["Subprocess
(stdin/stdout pipes)"]
PROC <-->|"JSON-RPC 2.0
newline-delimited"| CONN["Conn
(jsonrpc.go)"]
CONN -->|"initialize"| AGT["Agent
(claude, codex, gemini...)"]
CONN -->|"session/new"| AGT
CONN -->|"session/prompt"| AGT
CONN -->|"session/cancel"| AGT
AGT -->|"fs/readTextFile"| TB["ToolBridge
(tool_bridge.go)"]
AGT -->|"fs/writeTextFile"| TB
AGT -->|"terminal/*"| TERM["Terminal Registry
(terminal.go)"]
AGT -->|"permission/request"| TB
TB -->|"workspace sandbox"| FS["Filesystem"]
TERM -->|"deny patterns + allowlist"| CMD["Command Execution"]
```
**Key principles:**
- GoClaw is an ACP **client** — it spawns and controls agent subprocesses
- Each subprocess is a long-lived OS process communicating via stdin/stdout
- Security enforced at the tool bridge layer: workspace sandboxing, deny patterns, permission modes
---
## 2. Wire Protocol: JSON-RPC 2.0 over Stdio
### Transport
Messages are **newline-delimited JSON** on stdin/stdout. Each message is a complete JSON object followed by `\n`. The `Conn` type (`jsonrpc.go`, 217 lines) handles bidirectional communication.
```
GoClaw (Client) Agent (Server)
│ │
│──── {"jsonrpc":"2.0","id":1, ────►│ Request
│ "method":"initialize", │
│ "params":{...}} │
│ │
│◄─── {"jsonrpc":"2.0","id":1, ─────│ Response
│ "result":{...}} │
│ │
│◄─── {"jsonrpc":"2.0", ─────│ Notification (no id)
│ "method":"session/update", │
│ "params":{...}} │
│ │
│◄─── {"jsonrpc":"2.0","id":42, ─────│ Agent→Client Request
│ "method":"fs/readTextFile", │
│ "params":{"path":"..."}} │
│ │
│──── {"jsonrpc":"2.0","id":42, ────►│ Client→Agent Response
│ "result":{"content":"..."}} │
```
### Message Format
```go
type jsonrpcMessage struct {
JSONRPC string `json:"jsonrpc"` // always "2.0"
ID *int64 `json:"id,omitempty"` // present for requests/responses, absent for notifications
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *jsonrpcError `json:"error,omitempty"`
}
```
### Key Conn Methods
| Method | Purpose |
|--------|---------|
| `Call(ctx, method, params, &result)` | Send request, block until response (with context timeout) |
| `Notify(method, params)` | Fire-and-forget notification |
| `Start()` | Spawn `readLoop` goroutine for incoming messages |
| `Done()` | Channel closed when read loop exits (process died) |
**Buffer sizing:** Scanner uses 256KB initial / 10MB max per message — handles large file contents in tool bridge responses.
**ID sequencing:** Atomic `Int64` counter, no lock contention.
---
## 3. Session Lifecycle
```mermaid
sequenceDiagram
participant C as GoClaw (Client)
participant A as Agent (Subprocess)
C->>A: initialize {clientInfo, capabilities}
A->>C: initialize response {agentInfo, capabilities}
C->>A: session/new {}
A->>C: session/new response {sessionId}
loop Per user message
C->>A: session/prompt {sessionId, content}
A-->>C: session/update {kind:"message", content}
A-->>C: session/update {kind:"message", content}
A->>C: session/prompt response {stopReason}
end
Note over C,A: Optional: Cancel
C-->>A: session/cancel {sessionId}
```
### Phase 1: Initialize
Client declares capabilities (filesystem read/write, terminal support). Agent responds with identity and capabilities (audio, image, embedded context).
```go
// Client sends:
InitializeRequest{
ClientInfo: ClientInfo{Name: "goclaw", Version: "1.0"},
Capabilities: ClientCaps{
Fs: &FsCaps{Read: true, Write: true},
Terminal: &TerminalCaps{Create: true},
},
}
```
### Phase 2: New Session
Creates an isolated session on the agent. Returns `sessionId` used in all subsequent prompt calls.
### Phase 3: Prompt Loop
Send user content blocks (text + images). Agent streams `session/update` notifications with message deltas, tool call progress, and plan updates. Prompt completes with a response containing `stopReason`.
### Phase 4: Cancel (Optional)
Cooperative cancellation via `session/cancel` notification. Agent may take time to stop.
---
## 4. Content Handling
### ContentBlock Types
```go
type ContentBlock struct {
Type string `json:"type"` // "text", "image", "audio"
Text string `json:"text,omitempty"` // text content
Data string `json:"data,omitempty"` // base64 for image/audio
MimeType string `json:"mimeType,omitempty"` // e.g. "image/png"
}
```
### Request Extraction (GoClaw → Agent)
1. Extract system prompt + user message from `ChatRequest.Messages`
2. Prepend system prompt to first user message (ACP has no separate system message API)
3. Attach images as separate content blocks with base64 data
### Response Collection (Agent → GoClaw)
1. Accumulate `SessionUpdate` notifications during prompt execution
2. Collect text blocks into response content string
3. Map `stopReason` to GoClaw finish reason:
- `"maxContextLength"` → `"length"`
- All others → `"stop"`
### SessionUpdate Structure
```go
type SessionUpdate struct {
Kind string `json:"kind"` // "message", "toolCall", "plan"
Content []ContentBlock `json:"content,omitempty"`
ToolCall *ToolCallUpdate `json:"toolCall,omitempty"`
}
type ToolCallUpdate struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"` // "running", "completed"
Content []ContentBlock `json:"content,omitempty"`
}
```
---
## 5. Process Pool
`ProcessPool` (`process.go`, 237 lines) manages subprocess lifecycle.
### Spawn Flow
```
GetOrSpawn(sessionKey)
├→ Check cached process (sync.Map)
│ └→ Found + alive → return
├→ Acquire per-key spawn mutex (prevent thundering herd)
└→ spawn():
├→ exec.Command(binary, args...)
├→ cmd.Env = filterACPEnv(os.Environ()) // strip secrets
├→ Create stdin/stdout pipes
├→ cmd.Stderr = limitedWriter(4KB)
├→ cmd.Start()
├→ NewConn(stdin, stdout, toolBridge.Handle, notifyHandler)
├→ conn.Start() // begin readLoop
├→ Initialize() // ACP handshake
├→ NewSession() // create session
├→ Monitor exit in background goroutine
└→ Store in pool
```
### Idle Reaping
Every 30 seconds, the reaper checks all processes:
```go
for each process in pool:
if process.inUse > 0: skip // active prompt running
if time.Since(lastActive) > idleTTL:
process.cmd.Process.Kill() // SIGKILL
remove from pool
```
### Crash Recovery
If a process exits unexpectedly (detected via `<-proc.exited` channel), the next `GetOrSpawn` call automatically spawns a replacement. The active prompt is lost — caller receives an error.
### Concurrency Controls
| Mechanism | Purpose |
|-----------|---------|
| `sync.Map` for processes | Lock-free concurrent access |
| Per-key spawn mutex | Prevent duplicate spawns for same session |
| `inUse` atomic flag | Reaper skips active processes |
| `lastActive` timestamp | Tracks idle time for reaping |
| Session-level mutex in ACPProvider | Serializes prompts per session |
---
## 6. Tool Bridge (Agent → Client Requests)
`ToolBridge` (`tool_bridge.go`, 204 lines) handles all agent-initiated requests with security enforcement.
### Request Routing
| Method | Handler | Description |
|--------|---------|-------------|
| `fs/readTextFile` | `readFile()` | Read file within workspace |
| `fs/writeTextFile` | `writeFile()` | Write file within workspace |
| `terminal/createTerminal` | `createTerminal()` | Spawn command subprocess |
| `terminal/terminalOutput` | `terminalOutput()` | Get current output |
| `terminal/waitForTerminalExit` | `waitForExit()` | Block until exit (10-min timeout) |
| `terminal/releaseTerminal` | `releaseTerminal()` | Clean up resources |
| `terminal/killTerminal` | `killTerminal()` | Force-terminate |
| `permission/request` | `handlePermission()` | Permission check |
### Permission Modes
| Mode | Reads | Writes | Terminal | Permission Requests |
|------|-------|--------|----------|-------------------|
| `approve-all` | ✅ | ✅ | ✅ | ✅ (default) |
| `approve-reads` | ✅ | ❌ | ❌ | Per-type |
| `deny-all` | ❌ | ❌ | ❌ | ❌ |
### Workspace Sandbox
All file paths validated via `resolvePath()`:
```go
func resolvePath(path string) (string, error) {
abs := filepath.Join(workspace, path)
real, _ := filepath.EvalSymlinks(abs) // resolve symlinks
if !strings.HasPrefix(real, workspace) {
slog.Warn("security.acp_path_escape", ...)
return "", fmt.Errorf("path outside workspace")
}
return real, nil
}
```
Symlink resolution prevents `../../etc/passwd` attacks even when symlinks point outside workspace.
---
## 7. Terminal System
`Terminal` (`terminal.go`, 212 lines) manages command execution within the tool bridge.
### Security Layers
**1. Binary Allowlist (63 binaries):**
```
sh, bash, zsh, fish, node, npm, npx, pnpm, yarn, bun, deno,
python, python3, pip, pip3, uv, ruby, gem, go, cargo, rustc,
java, javac, mvn, gradle, dotnet, git, gh, docker, kubectl,
make, cmake, gcc, g++, clang, curl, wget, jq, yq, tar, zip,
unzip, gzip, cat, head, tail, less, grep, rg, find, ls, mv,
cp, mkdir, rm, chmod, touch, sed, awk, sort, wc, diff, tee
```
**2. Deny Patterns:** Regex patterns from GoClaw's `DefaultDenyPatterns` are applied to the full command string (binary + args).
**3. Working Directory Sandbox:** Terminal `cwd` validated against workspace boundary.
### cappedBuffer
Thread-safe circular buffer that retains only the last N bytes (default 10MB):
```go
type cappedBuffer struct {
mu sync.Mutex
data []byte
max int
}
// On overflow: keeps tail (recent output), discards head
```
Used for both stdout and stderr capture. Prevents unbounded memory growth from verbose agent output.
---
## 8. Environment Filtering
Before spawning any agent subprocess, `filterACPEnv()` strips sensitive environment variables:
**Prefix-based (12 prefixes):**
```
GOCLAW_, CLAUDE_, ANTHROPIC_, OPENAI_, DATABASE_, AWS_,
GOOGLE_, AZURE_, GITHUB_, DOCKER_, STRIPE_, SSH_
```
**Exact-match (15 keys):**
```
DB_DSN, PGPASSWORD, PGUSER, PGHOST, PGDATABASE, PGPORT,
REDIS_URL, MONGO_URI, NPM_TOKEN, SENTRY_AUTH_TOKEN,
SENTRY_DSN, DATADOG_API_KEY, TWILIO_AUTH_TOKEN,
SENDGRID_API_KEY, SLACK_TOKEN
```
This prevents credential leakage to untrusted agent binaries.
---
## 9. Configuration
### Config File (config.json)
```json5
{
"providers": {
"acp": {
"binary": "claude", // agent binary (must be in PATH)
"args": ["--profile", "goclaw"], // optional spawn args
"model": "claude", // default model name for routing
"work_dir": "/workspace", // base workspace directory
"idle_ttl": "5m", // process idle timeout
"perm_mode": "approve-all" // "approve-all" | "approve-reads" | "deny-all"
}
}
}
```
### Database Registration
Create via Providers API or Web UI:
| Field | Value |
|-------|-------|
| `provider_type` | `"acp"` |
| `api_base` | Binary name or absolute path (`"claude"`, `"/usr/local/bin/codex"`) |
| `settings` | `{"args": [...], "idle_ttl": "5m", "perm_mode": "approve-all", "work_dir": "..."}` |
Binary validation: Only `claude`, `codex`, `gemini`, or absolute paths are accepted for DB-based registration. Verified via `exec.LookPath()`.
### Gateway Wiring
```go
// Config-based: resolved at startup
registerACPFromConfig(registry, cfg.Providers.ACP)
// DB-based: resolved from llm_providers table
registerACPFromDB(registry, providerData)
```
Both paths:
1. Verify binary exists via `exec.LookPath`
2. Parse `IdleTTL` duration
3. Resolve `WorkDir` (default: `~/.goclaw/acp-workspaces`)
4. Create `NewACPProvider(binary, args, workDir, idleTTL, denyPatterns, opts...)`
### Live Reload
DB-based providers support live reload via pubsub. When a provider is created/updated/deleted in the Web UI, a `cache.invalidate` event triggers re-registration without gateway restart.
---
## 10. Streaming vs Non-Streaming
### Chat (Non-Streaming)
```go
func (p *ACPProvider) Chat(ctx, req) → *ChatResponse
```
1. Lock session mutex
2. `GetOrSpawn` process
3. `Prompt(content, onUpdate)` — blocks until complete
4. Collect all text deltas into `strings.Builder`
5. Return `ChatResponse{Content: text, FinishReason: mapped}`
### ChatStream
```go
func (p *ACPProvider) ChatStream(ctx, req, onChunk) → *ChatResponse
```
1. Lock session mutex
2. Set up cancel listener (`session/cancel` on context cancellation)
3. `GetOrSpawn` process
4. `Prompt(content, onUpdate)` with callback:
- Extract text blocks from each `SessionUpdate`
- Emit `StreamChunk{Content: delta}` via `onChunk`
5. On completion: emit `StreamChunk{Done: true}`
6. Return accumulated `ChatResponse`
---
## 11. Error Handling
| Scenario | Behavior |
|----------|----------|
| Binary not found | Log warning, skip provider registration |
| Subprocess crash mid-prompt | Active prompt fails; next `GetOrSpawn` respawns |
| Malformed JSON-RPC | Log debug, skip message, continue reading |
| Path escape attempt | Log `security.acp_path_escape`, return error to agent |
| Terminal binary not in allowlist | Return error to agent |
| Terminal deny pattern match | Return error to agent |
| Context cancelled (ChatStream) | Send `session/cancel`, return partial response |
| Idle timeout | Reaper kills process; respawned on next request |
| Permission denied | Return error based on `perm_mode` |
| Large output (>10MB terminal) | cappedBuffer retains tail only |
---
## 12. File Reference
| File | Lines | Purpose |
|------|-------|---------|
| `internal/providers/acp_provider.go` | 227 | Provider interface: Chat, ChatStream, content extraction |
| `internal/providers/acp/types.go` | 189 | ACP protocol types: Initialize, Session, ContentBlock |
| `internal/providers/acp/jsonrpc.go` | 217 | Bidirectional JSON-RPC 2.0 over stdio |
| `internal/providers/acp/process.go` | 237 | Subprocess pool: spawn, reap, crash recovery |
| `internal/providers/acp/session.go` | 71 | Session lifecycle: init → new → prompt → cancel |
| `internal/providers/acp/tool_bridge.go` | 204 | Agent→client request handler with sandbox |
| `internal/providers/acp/terminal.go` | 212 | Terminal subprocess lifecycle + cappedBuffer |
| `internal/providers/acp/helpers.go` | 81 | Environment filtering, limitedWriter |
| `internal/config/config_channels.go` | — | `ACPConfig` struct definition |
| `internal/store/provider_store.go` | — | `ProviderACP = "acp"` constant |
| `cmd/gateway_providers.go` | — | Config + DB registration wiring |
---
## Cross-References
| Document | Relevant Content |
|----------|-----------------|
| [02-providers.md](./02-providers.md) | ACP overview section (§10) |
| [03-tools-system.md](./03-tools-system.md) | Shell deny patterns reused by ToolBridge |
| [09-security.md](./09-security.md) | Defense-in-depth layers |
| [01-agent-loop.md](./01-agent-loop.md) | Chat/ChatStream provider contract |