Control Claude Code ACP from Livebook
Mix.install([
{:acpex, "~> 0.1.0"},
{:jason, "~> 1.4"},
{:kino, "~> 0.14"}
])
π Introduction
Welcome to an interactive demonstration of ACPex controlling Claude Code ACP agent!
In this livebook, youβll build a complete AI coding assistant interface that:
- π€ Connects to Claude Code ACP via the Agent Client Protocol (ACP)
- π¬ Sends prompts and receives intelligent responses
- π Allows the agent to read and write files on your system
- π₯οΈ Enables the agent to execute terminal commands
- π¨ Provides a beautiful interactive UI powered by Kino
This is a real, working implementation - not a simulation! Youβll be controlling an actual AI agent that can help you write code, analyze projects, and perform complex tasks.
π Prerequisites
Before running this livebook, ensure you have:
1. Claude Code ACP Installed
Option A: NPM (Node.js 20+)
npm install -g @zed-industries/claude-code-acp
2. Authentication
Claude Code ACP requires an Anthropic API key. Set the ANTHROPIC_API_KEY environment variable:
export ANTHROPIC_API_KEY="your-api-key-here"
Get your API key from: https://console.anthropic.com/settings/keys
3. Set up Environment
Livebook needs the PATH environment variable to find node (required for the claude-code-acp shebang):
Kino.Markdown.new("""
**Environment configured**
PATH includes: `node` at `#{System.find_executable("node") || "not found"}`
ANTHROPIC_API_KEY: is `#{System.get_env("ANTHROPIC_API_KEY", "not api key :(") |> String.slice(0..11)}`
""")
4. Verify Installation
Check that Claude Code ACP is available:
claude_code_acp_path = "claude-code-acp"
claude_acp_path = System.find_executable(claude_code_acp_path)
if claude_acp_path do
Kino.Markdown.new("""
β
**Claude Code ACP found!**
Path: `#{claude_acp_path}`
""")
else
Kino.Markdown.new("""
β **Claude Code ACP not found!**
Please install it using the NPM command above, then restart the runtime.
""")
end
π¨ Building the Interactive UI
Letβs create a beautiful dashboard for controlling the agent!
# Create UI components
defmodule UI do
def create_dashboard do
%{
# Status indicator
status_frame: Kino.Frame.new(),
# Agent output area
output_frame: Kino.Frame.new(),
# File operations log
file_log_frame: Kino.Frame.new(),
# Terminal output
terminal_frame: Kino.Frame.new(),
# Input controls
prompt_form:
Kino.Control.form(
[
prompt:
Kino.Input.textarea("π Your Prompt",
default: "Analyze the structure of this ACPex library and suggest improvements."
)
],
submit: "π€ Send Prompt",
reset_on_submit: []
),
model_selector:
Kino.Input.select(
"Model",
[
{"claude-opus-4", "Claude Opus 4 (Most Capable)"},
{"claude-sonnet-4", "Claude Sonnet 4 (Balanced)"},
{"claude-haiku-4", "Claude Haiku 4 (Fastest)"}
],
default: "claude-sonnet-4"
),
# Action buttons
start_button: Kino.Control.button("π Start Agent"),
stop_button: Kino.Control.button("π Stop Agent")
}
end
def render_dashboard(ui) do
Kino.Layout.grid(
[
# Header
Kino.Markdown.new("## π€ Claude Code ACP Agent Controller"),
# Status Section
Kino.Layout.grid([
Kino.Markdown.new("### Status"),
ui.status_frame
]),
# Control Section
Kino.Layout.grid([
Kino.Markdown.new("### Controls"),
Kino.Layout.grid([ui.start_button, ui.stop_button], columns: 2),
ui.model_selector
]),
# Prompt Section
Kino.Layout.grid([
Kino.Markdown.new("### Prompt"),
ui.prompt_form
]),
# Output Section
Kino.Layout.grid([
Kino.Markdown.new("### π¬ Agent Response"),
ui.output_frame
]),
# File Operations
Kino.Layout.grid([
Kino.Markdown.new("### π File Operations"),
ui.file_log_frame
]),
# Terminal Output
Kino.Layout.grid([
Kino.Markdown.new("### π₯οΈ Terminal Output"),
ui.terminal_frame
])
],
columns: 1
)
end
def update_status(frame, status, color \\ :blue) do
icon =
case status do
:disconnected -> "β"
:connecting -> "π"
:connected -> "β
"
:error -> "β"
_ -> "βΉοΈ"
end
color_code =
case color do
:green -> "#22c55e"
:red -> "#ef4444"
:blue -> "#3b82f6"
:yellow -> "#eab308"
_ -> "#6b7280"
end
Kino.Frame.render(
frame,
Kino.Markdown.new("""
#{icon} #{status |> to_string() |> String.upcase()}
""")
)
end
def log_file_operation(frame, operation, path, success \\ true) do
icon = if success, do: "β
", else: "β"
emoji =
case operation do
:read -> "π"
:write -> "βοΈ"
_ -> "π"
end
timestamp = DateTime.utc_now() |> DateTime.to_string()
Kino.Frame.append(
frame,
Kino.Markdown.new("""
`#{timestamp}` #{icon} #{emoji} **#{operation}** `#{path}`
""")
)
end
def log_terminal(frame, command, output \\ nil) do
content =
if output do
"""
```bash
$ #{command}
#{output}
```
"""
else
"""
```bash
$ #{command}
```
"""
end
Kino.Frame.append(frame, Kino.Markdown.new(content))
end
def render_agent_update(frame, update) do
content =
case update do
%{"kind" => "thought", "content" => text} ->
"π **Thinking:** #{text}"
%{"kind" => "message", "content" => text} ->
"π¬ **Response:**\n\n#{text}"
%{"kind" => "tool_call", "tool" => tool, "args" => args} ->
"π§ **Tool Call:** `#{tool}`\n```json\n#{Jason.encode!(args, pretty: true)}\n```"
%{"kind" => "plan", "steps" => steps} ->
steps_text =
steps
|> Enum.with_index(1)
|> Enum.map(fn {step, i} -> "#{i}. #{step}" end)
|> Enum.join("\n")
"π **Plan:**\n\n#{steps_text}"
_ ->
"βΉοΈ **Update:** #{inspect(update)}"
end
Kino.Frame.append(frame, Kino.Markdown.new(content <> "\n\n---\n"))
end
end
# Create and render the dashboard
ui = UI.create_dashboard()
dashboard = UI.render_dashboard(ui)
# Initialize status
UI.update_status(ui.status_frame, :disconnected, :red)
dashboard
π Implementing the ACPex Client
Now letβs create a full-featured client that implements all ACP callbacks:
defmodule ClaudeClient do
@moduledoc """
A complete ACPex client implementation for controlling Claude Code ACP.
This client handles:
- Session updates and streams them to the UI
- File system operations (read/write)
- Terminal operations (create, execute, monitor)
All callbacks use typed structs from ACPex.Schema for type safety and
automatic camelCase β snake_case conversion.
"""
@behaviour ACPex.Client
# Import schema types for typed struct usage
alias ACPex.Schema.Session.UpdateNotification
alias ACPex.Schema.Client.{FsReadTextFileResponse, FsWriteTextFileResponse}
alias ACPex.Schema.Client.Terminal
def init(args) do
ui = Keyword.fetch!(args, :ui)
state = %{
ui: ui,
sessions: %{},
terminals: %{},
files_accessed: []
}
{:ok, state}
end
@doc """
Handle streaming updates from the agent.
These come in as the agent thinks, plans, and generates responses.
Receives an UpdateNotification struct with the update content.
"""
def handle_session_update(%UpdateNotification{update: update}, state) do
# Render to the output frame
UI.render_agent_update(state.ui.output_frame, update)
{:noreply, state}
end
@doc """
Handle agent requests to read files.
The agent might need to read source code, config files, etc.
Receives a FsReadTextFileRequest struct, returns a FsReadTextFileResponse struct.
"""
def handle_fs_read_text_file(request, state) do
# Access the path from the typed struct
path = request.path
UI.log_file_operation(state.ui.file_log_frame, :read, path)
# Resolve relative paths from the current directory
full_path = Path.expand(path)
case File.read(full_path) do
{:ok, content} ->
UI.log_file_operation(state.ui.file_log_frame, :read, path, true)
new_state = %{state | files_accessed: [path | state.files_accessed]}
# Return a typed response struct
response = %FsReadTextFileResponse{content: content}
{:ok, response, new_state}
{:error, reason} ->
UI.log_file_operation(state.ui.file_log_frame, :read, path, false)
error = %{
code: -32001,
message: "Failed to read file: #{reason}"
}
{:error, error, state}
end
end
@doc """
Handle agent requests to write files.
The agent can create new files or modify existing ones.
Receives a FsWriteTextFileRequest struct, returns a FsWriteTextFileResponse struct.
"""
def handle_fs_write_text_file(request, state) do
# Access fields from the typed struct
path = request.path
content = request.content
UI.log_file_operation(state.ui.file_log_frame, :write, path)
full_path = Path.expand(path)
# Create parent directory if it doesn't exist
full_path |> Path.dirname() |> File.mkdir_p()
case File.write(full_path, content) do
:ok ->
UI.log_file_operation(state.ui.file_log_frame, :write, path, true)
new_state = %{state | files_accessed: [path | state.files_accessed]}
# Return a typed response struct (FsWriteTextFileResponse is empty)
response = %FsWriteTextFileResponse{}
{:ok, response, new_state}
{:error, reason} ->
UI.log_file_operation(state.ui.file_log_frame, :write, path, false)
error = %{
code: -32002,
message: "Failed to write file: #{reason}"
}
{:error, error, state}
end
end
@doc """
Create a new terminal for the agent to use.
Receives a Terminal.CreateRequest struct, returns a Terminal.CreateResponse struct.
"""
def handle_terminal_create(request, state) do
terminal_id = "term-" <> Base.encode16(:crypto.strong_rand_bytes(8), case: :lower)
UI.log_terminal(state.ui.terminal_frame, request.command)
# In a real implementation, you might use Port or System.cmd
# For this demo, we'll simulate it
terminal_info = %{
id: terminal_id,
command: request.command,
cwd: request.cwd || File.cwd!(),
output: ""
}
new_terminals = Map.put(state.terminals, terminal_id, terminal_info)
# Return typed response struct
response = %Terminal.CreateResponse{terminal_id: terminal_id}
{:ok, response, %{state | terminals: new_terminals}}
end
@doc """
Get output from a terminal.
Receives a Terminal.OutputRequest struct, returns a Terminal.OutputResponse struct.
"""
def handle_terminal_output(request, state) do
case Map.get(state.terminals, request.terminal_id) do
nil ->
error = %{code: -32003, message: "Terminal not found"}
{:error, error, state}
terminal ->
# Simulate running the command
{output, exit_code} =
try do
System.cmd("sh", ["-c", terminal.command],
cd: terminal.cwd,
stderr_to_stdout: true
)
rescue
_ -> {"Error executing command", 1}
end
# Update terminal with output
updated_terminal = %{terminal | output: output}
new_terminals = Map.put(state.terminals, request.terminal_id, updated_terminal)
UI.log_terminal(state.ui.terminal_frame, terminal.command, output)
# Return typed response struct
response = %Terminal.OutputResponse{
output: output,
truncated: false,
exit_status: %{exitCode: exit_code}
}
{:ok, response, %{state | terminals: new_terminals}}
end
end
@doc """
Wait for a terminal command to finish.
Receives a Terminal.WaitForExitRequest struct, returns a Terminal.WaitForExitResponse struct.
"""
def handle_terminal_wait_for_exit(request, state) do
case Map.get(state.terminals, request.terminal_id) do
nil ->
error = %{code: -32003, message: "Terminal not found"}
{:error, error, state}
_terminal ->
# In our simulation, commands finish immediately
response = %Terminal.WaitForExitResponse{exit_code: 0}
{:ok, response, state}
end
end
@doc """
Kill a running terminal command.
Receives a Terminal.KillRequest struct, returns a Terminal.KillResponse struct.
"""
def handle_terminal_kill(request, state) do
case Map.get(state.terminals, request.terminal_id) do
nil ->
error = %{code: -32003, message: "Terminal not found"}
{:error, error, state}
_terminal ->
new_terminals = Map.delete(state.terminals, request.terminal_id)
response = %Terminal.KillResponse{}
{:ok, response, %{state | terminals: new_terminals}}
end
end
@doc """
Release a terminal (cleanup).
Receives a Terminal.ReleaseRequest struct, returns a Terminal.ReleaseResponse struct.
"""
def handle_terminal_release(request, state) do
new_terminals = Map.delete(state.terminals, request.terminal_id)
response = %Terminal.ReleaseResponse{}
{:ok, response, %{state | terminals: new_terminals}}
end
end
:ok
π Starting and Initializing the Agent
Now letβs connect to Claude Code ACP! This cell will:
- Find the Claude Code ACP executable
- Start the ACPex client connection
- Spawn Claude Code ACP as a subprocess
- Automatically send the initialize message
β οΈ Note: Make sure youβve set your ANTHROPIC_API_KEY environment variable first
# Store the connection in the process dictionary so we can access it later
agent_connection = Process.get(:agent_connection)
if agent_connection && Process.alive?(agent_connection) do
Kino.Markdown.new("""
β οΈ **Agent already running!**
Click the π Stop Agent button below to stop it first.
""")
else
# Find Claude Code ACP
claude_acp_path = System.find_executable(claude_code_acp_path)
if claude_acp_path do
UI.update_status(ui.status_frame, :connecting, :blue)
# Start the agent
result =
ACPex.start_client(
ClaudeClient,
[ui: ui],
agent_path: claude_acp_path
)
case result do
{:ok, conn_pid} ->
# Store connection for later use
Process.put(:agent_connection, conn_pid)
Process.put(:current_session_id, nil)
# Immediately send initialize request (required by ACP protocol)
init_response =
ACPex.Protocol.Connection.send_request(
conn_pid,
"initialize",
%{
"protocolVersion" => 1,
"capabilities" => %{
"filesystem" => true,
"terminal" => true
},
"clientInfo" => %{
"name" => "Livebook",
"version" => "0.1.0"
}
},
# 30 second timeout
30_000
)
case init_response do
%{"result" => result} ->
UI.update_status(ui.status_frame, :connected, :green)
protocol_version = result["protocolVersion"] || result["protocol_version"]
agent_info = result["agentInfo"] || result["agent_info"]
capabilities = result["agentCapabilities"] || result["capabilities"]
Kino.Markdown.new("""
β
**Agent Connected and Initialized!**
**Agent Path:** `#{claude_acp_path}`
**Protocol Version:** #{protocol_version}
**Agent Info:**
```json
#{Jason.encode!(agent_info || %{}, pretty: true)}
```
**Capabilities:**
```json
#{Jason.encode!(capabilities || %{}, pretty: true)}
```
You're now ready to create a session and send prompts!
""")
%{"error" => error} ->
UI.update_status(ui.status_frame, :error, :red)
Kino.Markdown.new("""
β **Initialization Failed!**
Error: #{error["message"]}
""")
_ ->
UI.update_status(ui.status_frame, :error, :red)
Kino.Markdown.new("""
β οΈ **Unexpected Response**
#{inspect(init_response)}
""")
end
{:error, reason} ->
UI.update_status(ui.status_frame, :error, :red)
Kino.Markdown.new("""
β **Connection Failed!**
Error: #{inspect(reason)}
Make sure Claude Code ACP is properly installed and `ANTHROPIC_API_KEY` is set.
""")
end
else
UI.update_status(ui.status_frame, :error, :red)
Kino.Markdown.new("""
β **Claude Code ACP not found!**
Please install it and restart the runtime.
""")
end
end
π¬ Create a Session
Letβs create a conversation session:
agent_connection = Process.get(:agent_connection)
if agent_connection && Process.alive?(agent_connection) do
# Create a new session
session_response =
ACPex.Protocol.Connection.send_request(
agent_connection,
"session/new",
%{
"cwd" => File.cwd!(),
"mcpServers" => []
},
30_000
)
case session_response do
%{"result" => result} ->
# Get session ID (accept both camelCase and snake_case)
session_id = result["sessionId"] || result["session_id"]
# Store session ID for later
Process.put(:current_session_id, session_id)
Kino.Markdown.new("""
β
**Session Created!**
Session ID: `#{session_id}`
You're now ready to send prompts to the agent!
""")
%{"error" => error} ->
Kino.Markdown.new("""
β **Session Creation Failed!**
Error: #{error["message"]}
""")
_ ->
Kino.Markdown.new("""
β οΈ **Unexpected Response**
#{inspect(session_response)}
""")
end
else
Kino.Markdown.new("""
β οΈ **Agent not connected!**
Please run the "Starting the Agent Connection" cell above first.
""")
end
π― Send Prompts to the Agent
Now for the fun part! Use the interactive form to send prompts:
# Get connection and session
agent_connection = Process.get(:agent_connection)
session_id = Process.get(:current_session_id)
# Listen to the form submission
form_stream = Kino.Control.stream(ui.prompt_form)
Kino.listen(form_stream, fn event ->
if agent_connection && Process.alive?(agent_connection) && session_id do
# Clear the output frame
Kino.Frame.clear(ui.output_frame)
# Get the prompt from the form event
prompt_text = event.data.prompt
if prompt_text && String.trim(prompt_text) != "" do
# Show that we're sending
UI.render_agent_update(ui.output_frame, %{
"kind" => "message",
"content" => "**You:** #{prompt_text}"
})
# Send the prompt
Task.async(fn ->
response =
ACPex.Protocol.Connection.send_request(
agent_connection,
"session/prompt",
%{
"sessionId" => session_id,
"prompt" => [
%{
"type" => "text",
"text" => prompt_text
}
]
},
# 2 minute timeout
120_000
)
case response do
%{"result" => result} ->
stop_reason = result["stopReason"] || result["stop_reason"]
UI.render_agent_update(ui.output_frame, %{
"kind" => "message",
"content" => "β
**Complete!**\n\nStop Reason: #{stop_reason}"
})
%{"error" => error} ->
UI.render_agent_update(ui.output_frame, %{
"kind" => "message",
"content" => "β **Error:** #{error["message"]}"
})
_ ->
UI.render_agent_update(ui.output_frame, %{
"kind" => "message",
"content" => "β οΈ Unexpected response: #{inspect(response)}"
})
end
end)
end
else
Kino.Frame.render(
ui.output_frame,
Kino.Markdown.new("β οΈ **Not connected!** Please connect and initialize first.")
)
end
end)
Kino.Markdown.new("""
β¨ **Ready to chat!**
Type your prompt in the form above and click "π€ Send Prompt" to submit.
The agent will:
- Stream its thinking process in real-time
- Read files if needed (you'll see them in the File Operations log)
- Execute commands if needed (you'll see them in Terminal Output)
- Generate intelligent responses
Try asking:
- "Analyze the architecture of this ACPex library"
- "What are the main modules and what do they do?"
- "Find all public functions and generate documentation stubs"
- "Add a test for the ACPex.Json.decode function"
""")
π Stop the Agent
When youβre done, stop the agent:
# Listen to stop button
stop_stream = Kino.Control.stream(ui.stop_button)
Kino.listen(stop_stream, fn _event ->
agent_connection = Process.get(:agent_connection)
if agent_connection && Process.alive?(agent_connection) do
# Stop the connection
Process.exit(agent_connection, :normal)
Process.put(:agent_connection, nil)
Process.put(:current_session_id, nil)
UI.update_status(ui.status_frame, :disconnected, :red)
Kino.Frame.render(
ui.output_frame,
Kino.Markdown.new("π **Agent stopped!**")
)
end
end)
Kino.Markdown.new("β
Stop button listener active")
π Example Prompts to Try
Here are some interesting prompts to explore ACPexβs capabilities:
1. Code Analysis
Analyze the ACPex.Protocol.Connection module. Explain its role in the architecture
and how it manages sessions.
2. Generate Tests
Look at lib/acpex/json.ex and generate comprehensive unit tests for the decode
functions. Place them in test/acpex/json_test.exs.
3. Documentation
Review all public functions in lib/acpex.ex and ensure they have proper @doc
and @spec annotations. Add any that are missing.
4. Refactoring
Examine the session routing logic in lib/acpex/protocol/session.ex and suggest
any improvements for clarity or performance.
5. Create New Feature
Add a new function to ACPex that lists all active connections and their session
counts. Include tests.
π Summary
Congratulations! Youβve built a complete, working integration between:
- Livebook - Your interactive notebook environment
- Kino - Beautiful, reactive UI components
- ACPex - Your Elixir ACP protocol implementation
- Claude Code ACP - Anthropicβs powerful AI coding assistant
What You Learned
- Protocol Implementation: How to implement a full ACPex client with all callbacks
- File System Integration: How agents can read and write files through your client
- Terminal Integration: How agents can execute commands safely
- Real-time UI: How to build reactive interfaces with Kino
- Agent Communication: How the Agent Client Protocol enables tool interoperability
Next Steps
- Experiment with different prompts and see what Claude can do
- Try implementing additional ACP features (like session persistence)
- Build your own custom agent using ACPex
- Create a web interface using Phoenix LiveView
- Integrate with other MCP-compatible tools
Resources
- ACPex Documentation: Check out the HexDocs for ACPex
- ACP Specification: https://agentclientprotocol.com
- Claude Code ACP: https://github.com/zed-industries/claude-code-acp
- Kino Documentation: https://hexdocs.pm/kino
Happy Coding! π