Loom integrates with code editors through the Agent Client Protocol (ACP) , enabling seamless AI assistance directly in your development environment.
Supported Editors
VS Code Official support via ACP over stdio
Zed Native ACP support (built-in)
Custom Clients Any editor with ACP support
Agent Client Protocol (ACP)
ACP is a protocol for connecting AI agents to code editors. Loom implements ACP to provide a consistent interface across different editors.
Architecture
┌─────────────────┐ stdio ┌──────────────────┐
│ │ <──────────────────────>│ │
│ Code Editor │ JSON-RPC over │ Loom ACP │
│ (VS Code, Zed) │ stdin/stdout │ Agent │
│ │ │ │
└─────────────────┘ └──────────────────┘
│
│
┌────────▼─────────┐
│ │
│ Loom Runtime │
│ - LLM Clients │
│ - Tool Registry │
│ - Thread Store │
│ │
└──────────────────┘
Key concepts:
An ACP session corresponds to a Loom thread (conversation). Sessions persist across editor restarts and maintain full conversation history. // loom-cli-acp/src/bridge.rs:121
pub fn thread_id_to_session_id ( thread_id : & ThreadId ) -> SessionId {
SessionId :: new ( thread_id . to_string ())
}
pub fn session_id_to_thread_id ( session_id : & SessionId ) -> ThreadId {
ThreadId :: from_string ( session_id . to_string ())
}
User input is sent as ACP content blocks (text, images, etc.). Loom currently supports text blocks: // loom-cli-acp/src/bridge.rs:26
pub fn content_blocks_to_user_message ( blocks : & [ ContentBlock ]) -> Message {
let text = blocks
. iter ()
. filter_map ( | block | match block {
ContentBlock :: Text ( t ) => Some ( t . text . as_str ()),
_ => None , // Ignore other block types for now
})
. collect :: < Vec < _ >>()
. join ( " \n " );
Message :: user ( & text )
}
Assistant responses are streamed as content chunks for real-time display: // loom-cli-acp/src/bridge.rs:40
pub fn text_to_content_chunk ( text : String ) -> ContentChunk {
ContentChunk :: new ( text . into ())
}
Tool calls are executed locally (not in the editor). The agent runs tools via Loom’s tool registry and streams results back.
Message Flow
VS Code (via ACP)
Connect Loom to VS Code using the ACP protocol over stdio.
Setup
Install Loom CLI
# Build from source
cargo build --release -p loom-cli
# Or download binary
curl -L https://loom.ghuntley.com/download/loom-cli > loom
chmod +x loom
Configure VS Code
Create a VS Code task to launch the ACP agent: .vscode/tasks.json:{
"version" : "2.0.0" ,
"tasks" : [
{
"label" : "Loom ACP Agent" ,
"type" : "shell" ,
"command" : "loom" ,
"args" : [ "acp" ],
"isBackground" : true ,
"problemMatcher" : []
}
]
}
Or configure in settings: {
"acp.agents" : [
{
"name" : "Loom" ,
"command" : "loom" ,
"args" : [ "acp" ],
"models" : [
"claude-3-5-sonnet-20241022" ,
"gpt-4-turbo" ,
"gemini-1.5-pro"
]
}
]
}
Start Session
Open the Command Palette (Cmd+Shift+P / Ctrl+Shift+P)
Run “ACP: Start Session”
Select “Loom” from the agent list
Start chatting in the ACP panel
Authentication
The ACP agent uses the same authentication as the CLI:
# Device code flow (recommended)
loom login
# Or set API keys directly
export ANTHROPIC_API_KEY = sk-ant-api03- ...
export OPENAI_API_KEY = sk- ...
Credentials are stored in ~/.loom/credentials.json and automatically used by the ACP agent.
Available Commands
The ACP agent supports all Loom tools:
read_file Read file contents
write_file Create or overwrite files
edit_file Search and replace in files
list_files List directory contents
search_files Search code with regex
run_command Execute shell commands
github_search Search GitHub code
Example interaction:
You: Create a new Rust function to parse JSON
Assistant: I'll create a JSON parser function for you.
[Tool: write_file]
path: src/parser.rs
content: ...
Done! I've created src/parser.rs with a JSON parsing function.
Implementation Details
Session Management
Sessions map 1:1 to Loom threads:
// loom-cli-acp/src/session.rs
pub struct SessionState {
pub session_id : SessionId ,
pub thread_id : ThreadId ,
pub created_at : DateTime < Utc >,
pub last_activity : DateTime < Utc >,
}
Session lifecycle:
// loom-cli-acp/src/agent.rs
impl Agent for LoomAcpAgent {
async fn create_session ( & self , request : CreateSessionRequest ) -> Result < Session , Error > {
// Create new thread
let thread = Thread :: new (
ThreadId :: generate (),
request . name . unwrap_or_default (),
Utc :: now (),
);
// Persist
self . thread_store . save ( & thread ) . await ? ;
// Track session
let session_id = thread_id_to_session_id ( & thread . id);
self . sessions . write () . await . insert (
session_id . clone (),
SessionState {
session_id : session_id . clone (),
thread_id : thread . id . clone (),
created_at : Utc :: now (),
last_activity : Utc :: now (),
},
);
Ok ( Session {
id : session_id ,
name : thread . name . clone (),
created_at : thread . created_at,
})
}
}
Streaming Implementation
Loom streams LLM responses as ACP notifications:
// loom-cli-acp/src/agent.rs
async fn send_message ( & self , request : SendMessageRequest ) -> Result <(), Error > {
let session = self . get_session ( & request . session_id) . await ? ;
let thread = self . thread_store . load ( & session . thread_id) . await ? ;
// Convert ACP blocks to Loom message
let user_message = content_blocks_to_user_message ( & request . content);
// Build LLM request
let llm_request = LlmRequest :: new ( & self . config . model)
. with_messages ( thread_to_messages ( & thread ))
. with_messages ( vec! [ user_message . clone ()])
. with_tools ( self . tool_registry . to_llm_tools ());
// Stream response
let mut stream = self . llm_client . stream ( llm_request ) . await ? ;
while let Some ( event ) = stream . next () . await {
match event ? {
LlmEvent :: ContentDelta ( text ) => {
// Send to editor
self . send_notification (
& request . session_id,
SessionNotification :: ContentDelta ( text_to_content_chunk ( text )),
) . await ? ;
}
LlmEvent :: ToolCall ( tool_call ) => {
// Execute tool locally
let result = self . tool_registry . execute ( & tool_call ) . await ? ;
// Send result back to LLM
// (continues streaming)
}
LlmEvent :: Done { usage } => {
// Persist conversation
thread . add_message ( message_to_snapshot ( & user_message ));
thread . add_message ( message_to_snapshot ( & assistant_message ));
self . thread_store . save ( & thread ) . await ? ;
// Notify completion
self . send_notification (
& request . session_id,
SessionNotification :: Done {
stop_reason : StopReason :: EndTurn ,
},
) . await ? ;
break ;
}
}
}
Ok (())
}
Type Conversions
All conversions between ACP and Loom types are pure functions:
// loom-cli-acp/src/bridge.rs
// Message persistence
pub fn message_to_snapshot ( message : & Message ) -> MessageSnapshot {
MessageSnapshot {
role : loom_role_to_thread_role ( message . role . clone ()),
content : message . content . clone (),
tool_call_id : message . tool_call_id . clone (),
tool_name : message . name . clone (),
tool_calls : if message . tool_calls . is_empty () {
None
} else {
Some ( message . tool_calls . iter () . map ( tool_call_to_snapshot ) . collect ())
},
}
}
// Thread restoration
pub fn thread_to_messages ( thread : & Thread ) -> Vec < Message > {
thread
. conversation
. messages
. iter ()
. map ( snapshot_to_message )
. collect ()
}
// Stop reason mapping
pub fn map_stop_reason ( had_error : bool , cancelled : bool ) -> StopReason {
if had_error || cancelled {
StopReason :: Cancelled
} else {
StopReason :: EndTurn
}
}
Custom ACP Clients
You can build custom ACP clients for any editor or environment:
Protocol Overview
ACP uses JSON-RPC 2.0 over stdio:
// Request (stdin)
{
"jsonrpc" : "2.0" ,
"id" : 1 ,
"method" : "session/create" ,
"params" : {
"name" : "My Coding Session"
}
}
// Response (stdout)
{
"jsonrpc" : "2.0" ,
"id" : 1 ,
"result" : {
"id" : "thread_abc123" ,
"name" : "My Coding Session" ,
"created_at" : "2025-03-03T12:00:00Z"
}
}
// Notification (stdout)
{
"jsonrpc" : "2.0" ,
"method" : "session/notification" ,
"params" : {
"session_id" : "thread_abc123" ,
"notification" : {
"type" : "content_delta" ,
"delta" : { "text" : "Here's the code you requested..." }
}
}
}
Supported Methods
Session Management
Messaging
Configuration
// Create session
session / create {
name ?: string
} -> {
id: string ,
name: string ,
created_at: string
}
// List sessions
session / list {} -> {
sessions: Array <{
id : string ,
name : string ,
created_at : string ,
last_activity : string
}>
}
// Delete session
session / delete {
session_id : string
} -> {}
// Send message
session / send {
session_id : string ,
content : Array <{
type : "text" ,
text : string
}>
} -> {}
// Notifications (server -> client)
session / notification {
session_id : string ,
notification :
| { type : "content_delta" , delta : { text : string }}
| { type : "tool_call" , tool : { name : string , args : object }}
| { type : "done" , stop_reason : "end_turn" | "cancelled" }
| { type : "error" , message : string }
}
// Get capabilities
agent / info {} -> {
name: "Loom" ,
version: "1.0.0" ,
models: [ "claude-3-5-sonnet-20241022" , ... ],
tools: [ "read_file" , "write_file" , ... ]
}
Example Client (Python)
import json
import subprocess
import sys
class LoomAcpClient :
def __init__ ( self ):
self .proc = subprocess.Popen(
[ "loom" , "acp" ],
stdin = subprocess. PIPE ,
stdout = subprocess. PIPE ,
text = True ,
bufsize = 1
)
self .request_id = 0
def send_request ( self , method , params ):
self .request_id += 1
request = {
"jsonrpc" : "2.0" ,
"id" : self .request_id,
"method" : method,
"params" : params
}
self .proc.stdin.write(json.dumps(request) + " \n " )
self .proc.stdin.flush()
# Read response
response = json.loads( self .proc.stdout.readline())
return response.get( "result" )
def listen_notifications ( self ):
while True :
line = self .proc.stdout.readline()
if not line:
break
msg = json.loads(line)
if "method" in msg: # Notification
yield msg[ "params" ]
# Usage
client = LoomAcpClient()
# Create session
session = client.send_request( "session/create" , { "name" : "Test" })
print ( f "Session created: { session[ 'id' ] } " )
# Send message
client.send_request( "session/send" , {
"session_id" : session[ "id" ],
"content" : [{ "type" : "text" , "text" : "Write a hello world function" }]
})
# Listen for response
for notification in client.listen_notifications():
if notification[ "notification" ][ "type" ] == "content_delta" :
print (notification[ "notification" ][ "delta" ][ "text" ], end = "" )
elif notification[ "notification" ][ "type" ] == "done" :
print ( " \n Done!" )
break
Debugging
Enable Debug Logs
# Verbose output
LOOM_LOG = debug loom acp
# Trace all messages
LOOM_LOG = trace loom acp
# Log to file
LOOM_LOG = debug loom acp 2> acp-debug.log
Inspect Protocol Messages
Use tee to capture stdio traffic:
# Capture input/output
loom acp 2>&1 | tee acp-output.log
Common Issues
Symptom: Editor shows “Failed to start agent”Solutions:
Check loom is in PATH: which loom
Test manually: loom acp
Check logs: LOOM_LOG=debug loom acp
Symptom: “Unauthorized” or “Invalid API key”Solutions:
Run loom login to authenticate
Check credentials: cat ~/.loom/credentials.json
Set API keys: export ANTHROPIC_API_KEY=...
Best Practices
Persist Sessions Sessions are automatically persisted to disk. You can resume conversations after restarting the editor.
Use Specific Models Configure model per session based on task complexity. Use Claude Sonnet for coding, GPT-4 for general tasks.
Monitor Token Usage Track token usage in notifications. Large codebases can consume significant context.
Graceful Shutdown The agent handles SIGTERM/SIGINT gracefully, ensuring all threads are saved before exit.
Source Code Reference
ACP Agent: crates/loom-cli-acp/src/agent.rs
Type Bridge: crates/loom-cli-acp/src/bridge.rs
Session Management: crates/loom-cli-acp/src/session.rs
CLI Integration: crates/loom-cli/src/main.rs