| name | agent-client-protocol |
| description | Implement or debug Agent Client Protocol (ACP) support in Rust for siGit Code. Use when working on ACP JSON-RPC over stdio, the agent-client-protocol crate, session/prompt/fork handlers, config options (model picker), slash commands, streaming notifications, or editor integration. |
Skill: Agent Client Protocol (ACP) — Rust Implementation
Overview
ACP is a JSON-RPC 2.0 protocol over stdio for integrating AI coding agents
with editors (Zed, JetBrains, Neovim, etc.). The agent runs as a subprocess;
the editor is the client. Communication is newline-delimited JSON on stdin/stdout.
Crate: agent-client-protocol = "0.13" (siGit pins 0.13.0 in Cargo.lock)
Docs: https://docs.rs/agent-client-protocol
Spec: https://agentclientprotocol.com
Big change since 0.10: the crate moved from a #[async_trait(?Send)] impl Agent
model to a builder model. You no longer implement a trait. You build an
Agent with per-message handler closures and .connect_to(transport). Each
handler receives a ConnectionTo<Client> (cx) you use to send notifications
and spawn tasks — so the old mpsc "circular dependency" pattern is gone.
siGit's entire ACP server lives in src/main.rs (run_acp_server, the
SiGitAgent struct, and its handle_* methods). Read it alongside this skill.
Dependency setup
[dependencies]
agent-client-protocol = { version = "0.13", features = [
"unstable_session_fork",
"unstable_session_additional_directories",
"unstable_auth_methods",
] }
async-trait = "0.1"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "io-std", "io-util", "sync", "time"] }
tokio-util = { version = "0.7", features = ["compat"] }
futures = "0.3"
uuid = { version = "1", features = ["v4"] }
The unstable_* features gate real types/methods (ForkSessionRequest,
additional_directories, AuthMethod::Agent). Without them the corresponding
APIs don't exist and you'll get "no variant/method" errors.
Imports
Protocol message/data types live under agent_client_protocol::schema::*.
Connection/runtime types live at the crate root.
use agent_client_protocol::schema::{
AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest, AuthenticateResponse,
AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, CancelNotification,
ConfigOptionUpdate, ContentBlock, ContentChunk, EmbeddedResourceResource, ForkSessionRequest,
ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse, LoadSessionRequest,
LoadSessionResponse, Meta, NewSessionRequest, NewSessionResponse, PromptRequest,
PromptResponse, ProtocolVersion, SessionCapabilities, SessionConfigOption,
SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigValueId,
SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate,
SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, ToolCall,
ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,
};
use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
Wiring up the server — the builder
You do not implement a trait. You hold your state in an Arc<MyState>, then
register one closure per incoming message type on Agent.builder(), and finish
with .connect_to(transport).await. The builder owns the JSON-RPC loop and runs
until the client disconnects.
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
async fn run_acp_server() -> anyhow::Result<()> {
let state = Arc::new(SiGitAgent::new());
let stdin = tokio::io::stdin().compat();
let stdout = tokio::io::stdout().compat_write();
let transport = ByteStreams::new(stdout, stdin);
Agent
.builder()
.on_receive_request(
{
let state = Arc::clone(&state);
async move |req: InitializeRequest, responder, _cx: ConnectionTo<Client>| {
handle_response(responder, state.handle_initialize(req).await)
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let state = Arc::clone(&state);
async move |req: PromptRequest, responder, cx: ConnectionTo<Client>| {
handle_response(responder, state.handle_prompt(&cx, req).await)
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_notification(
{
let state = Arc::clone(&state);
async move |notif: CancelNotification, _cx: ConnectionTo<Client>| {
state.handle_cancel(notif).await
}
},
agent_client_protocol::on_receive_notification!(),
)
.connect_to(transport)
.await
.map_err(|e| anyhow::anyhow!("ACP connection error: {e}"))?;
Ok(())
}
Key points:
Arc::clone(&state) per closure. Each handler closure is move and owns
its own Arc clone of shared state.
- The macro is required. Each handler is paired with
agent_client_protocol::on_receive_request!() (or on_receive_notification!()).
It wires the closure's concrete message type into the dispatcher. Don't omit it.
- Closure signature for requests:
async move |req: T, responder, cx: ConnectionTo<Client>|.
Use _cx when a handler doesn't send notifications (e.g. initialize, authenticate).
- Closure signature for notifications:
async move |notif: T, cx: ConnectionTo<Client>|
returning agent_client_protocol::Result<()> — no responder (notifications get no reply).
- Unmatched messages fall to the SDK default — you only register what you support.
The Responder + handle_response helper
Requests reply through a Responder<T>. siGit funnels every handler's
Result through one helper:
fn handle_response<T: agent_client_protocol::JsonRpcResponse>(
responder: Responder<T>,
result: agent_client_protocol::Result<T>,
) -> agent_client_protocol::Result<()> {
match result {
Ok(resp) => responder.respond(resp),
Err(err) => responder.respond_with_error(err),
}
}
So each handle_* method just returns Result<SomeResponse> and stays free of
protocol plumbing.
ConnectionTo<Client> — the cx
The per-handler cx: ConnectionTo<Client> replaces the old mpsc-channel forwarder.
It is Clone. Two things you do with it:
cx.send_notification(SessionNotification::new(session_id.clone(), update))?;
let cx_for_poller = cx.clone();
cx.spawn(async move {
loop {
# break;
}
Ok(())
}).ok();
Because cx is handed to you directly, there is no circular dependency between
the connection and the agent anymore. Don't reintroduce the mpsc forwarder pattern.
The handlers siGit implements
| Message | Method | Notes |
|---|
InitializeRequest | handle_initialize | capabilities, auth methods, agent info, meta |
AuthenticateRequest | handle_authenticate | verifies stored siGit Code Cloud session |
NewSessionRequest | handle_new_session | sets cwd, resets history, advertises commands + config options |
LoadSessionRequest | handle_load_session | like new_session; gated by load_session(true) capability |
ForkSessionRequest | handle_fork_session | gated by unstable_session_fork + SessionForkCapabilities |
PromptRequest | handle_prompt | the turn: parse blocks → slash commands or tool-calling loop |
SetSessionConfigOptionRequest | handle_set_session_config_option | the Zed model picker — switches/downloads models |
CancelNotification | handle_cancel | notification, no response |
Everything else is left to the SDK default (method not found).
Types and their builders
All #[non_exhaustive] structs require builder methods — struct-literal syntax
won't compile.
InitializeResponse
Ok(InitializeResponse::new(ProtocolVersion::V1)
.agent_info(
Implementation::new("sigit", env!("CARGO_PKG_VERSION"))
.title("siGit Code - AI Coding Agent"),
)
.auth_methods(vec![AuthMethod::Agent(
AuthMethodAgent::new("sigit", "Sign in to siGit Code")
.description("Sign in with `/login <email> <password>` in the message box."),
)])
.agent_capabilities(
AgentCapabilities::default()
.load_session(true)
.session_capabilities(
SessionCapabilities::new()
.fork(SessionForkCapabilities::new()),
),
)
.meta(initialize_meta()))
auth_methods must include at least one AuthMethod::Agent or Zed hangs on
"Loading…" forever. siGit uses Agent (not Terminal) because Zed advertises
terminal-auth for custom agents but never actually spawns the login terminal, so
the button would be a silent no-op. With Agent, clicking calls authenticate.
Meta — free-form server metadata
Meta is a string-keyed JSON map you can attach to InitializeResponse (siGit
publishes the active model there so the editor can show it):
let mut meta = Meta::new();
meta.insert("sigit".to_string(), serde_json::json!({
"active_model": { "display_name": "...", "model_id": "...", "gguf_file": "..." }
}));
AuthenticateResponse
Ok(AuthenticateResponse::default())
NewSessionResponse / LoadSessionResponse / ForkSessionResponse
let session_id = SessionId::new(uuid::Uuid::new_v4().to_string());
Ok(NewSessionResponse::new(session_id).config_options(config_options))
Ok(LoadSessionResponse::new().config_options(config_options))
Ok(ForkSessionResponse::new(new_id).config_options(config_options))
SessionId is a newtype with Clone, PartialEq, Display, Into<String>,
AsRef<str>. Store it as-is so == works. config_options powers the editor's
per-session picker (see Config options below).
The session requests carry cwd: PathBuf and (with the feature)
additional_directories: Vec<PathBuf>. siGit stashes cwd, set_current_dirs
to it, and pushes a system message telling the model to use absolute paths under it.
PromptRequest / blocks
args.session_id
args.prompt
Editors send several block kinds — handle the three siGit cares about:
for block in &args.prompt {
match block {
ContentBlock::Text(t) => { }
ContentBlock::Resource(embedded) => match &embedded.resource {
EmbeddedResourceResource::TextResourceContents(tr) => { }
EmbeddedResourceResource::BlobResourceContents(b) => { }
_ => {}
},
ContentBlock::ResourceLink(link) => {
}
_ => {}
}
}
PromptResponse
Ok(PromptResponse::new(StopReason::EndTurn))
Streaming: ContentChunk + SessionUpdate + SessionNotification
let chunk = ContentChunk::new(ContentBlock::from(delta_text));
let update = SessionUpdate::AgentMessageChunk(chunk);
cx.send_notification(SessionNotification::new(session_id.clone(), update))?;
SessionUpdate variants siGit uses:
AgentMessageChunk(ContentChunk) — assistant text.
ToolCall(ToolCall) — start a tool-call card (used for model load/download progress).
ToolCallUpdate(ToolCallUpdate) — update that card's title/status/content.
AvailableCommandsUpdate(AvailableCommandsUpdate) — advertise slash commands.
ConfigOptionUpdate(ConfigOptionUpdate) — refresh the picker mid-session.
(Other variants exist: UserMessageChunk, AgentThoughtChunk, Plan, …)
ToolCall / ToolCallUpdate — progress cards
siGit reuses tool-call cards as a generic progress UI (model loading/download):
SessionUpdate::ToolCall(
ToolCall::new(tool_call_id.clone(), "Loading Qwen 2.5 3B")
.kind(ToolKind::Think)
.status(ToolCallStatus::InProgress)
.content(vec!["Loading…".into()]),
)
SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
tool_call_id.clone(),
ToolCallUpdateFields::new()
.title("✓ Qwen 2.5 3B loaded")
.status(ToolCallStatus::Completed),
))
ToolCallStatus: InProgress, Completed, Failed. ToolKind::Think is the
"thinking/util" kind.
Error
agent_client_protocol::Error::new(-32603, "internal error message")
agent_client_protocol::Error::new(-32602, "invalid params: …")
agent_client_protocol::Error::new(-32000, "not signed in …")
Config options — the editor model picker
ACP lets the agent expose per-session config controls; Zed renders them in the
agent panel. siGit uses one select option as a model picker.
const MODEL_CONFIG_ID: &str = "sigit-model";
let options: Vec<SessionConfigSelectOption> = models.iter().map(|m| {
SessionConfigSelectOption::new(
SessionConfigValueId::new(m.model_id.as_str()),
format!("{} {badge}", m.display_name),
).description(desc)
}).collect();
let config_options = vec![
SessionConfigOption::select(MODEL_CONFIG_ID, "Model", current_value, options)
.category(SessionConfigOptionCategory::Model)
.description("Select an on-device model or a siGit Code Cloud tier"),
];
Return these from new/load/fork session via .config_options(config_options).
When the user picks one, the client sends SetSessionConfigOptionRequest:
async fn handle_set_session_config_option(&self, cx: &ConnectionTo<Client>,
args: SetSessionConfigOptionRequest) -> Result<SetSessionConfigOptionResponse> {
if args.config_id.0.as_ref() != MODEL_CONFIG_ID { return Err(Error::new(-32602, "…")); }
let model_id = args.value.0.as_ref();
Ok(SetSessionConfigOptionResponse::new(rebuilt_config_options))
}
To refresh the picker mid-session (e.g. after /reload), push
SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)).
Gotcha: Zed re-fires the last selection on (re)connect. Guard against a no-op
re-select of the already-active model, and don't try to load a new model while a
startup load is still in flight (the old weights still hold GPU memory → the new
load fails with "does not fit"). siGit waits for model_ready first.
Slash commands
Advertise them so the editor forwards /-prefixed input (Zed rejects unknown
slash commands client-side):
let commands = vec![
AvailableCommand::new("help", "Show available commands"),
AvailableCommand::new("models", "List available models").input(
AvailableCommandInput::Unstructured(UnstructuredCommandInput::new(
"model number to switch to (optional)"))),
];
cx.send_notification(SessionNotification::new(
session_id,
SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(commands)),
))?;
siGit parses slash text out of the prompt itself (parse_slash) and dispatches in
exec_slash_acp before falling through to inference. The command turn still ends
with Ok(PromptResponse::new(StopReason::EndTurn)).
Concurrency: the block_in_place trap (still real)
mistralrs model loading calls tokio::task::block_in_place internally, which
panics off a multi-threaded runtime worker ("can call blocking only when
running on the multi-threaded runtime"). The builder's task context and
cx.spawn tasks are not safe for this.
siGit's fix: do the blocking model load on a dedicated std::thread with its
own fresh tokio::runtime::Runtime, and signal completion back via an
AtomicBool / oneshot channel. Never call load_gguf_model directly inside a
prompt handler or a cx.spawn task.
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(loader_engine.load_gguf_model(cfg, prompt, sampling));
});
The prompt handler then awaits readiness (siGit polls model_ready on a 1s
tokio::time::interval, streaming a spinner via cx.send_notification).
Logging
stdout is the ACP JSON-RPC wire — log only to stderr. siGit uses
tracing_subscriber to stderr:
tracing_subscriber::fmt::Subscriber::builder()
.with_env_filter(EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info")))
.with_writer(std::io::stderr)
.try_init();
In siGit's interactive TTY mode (not ACP), it goes further and redirects the
stdout/stderr fds to $TMPDIR/sigit.log so mistralrs/native noise can't
corrupt the ratatui screen. ACP mode keeps stdout pristine for protocol JSON.
TTY vs ACP split
main() decides mode from std::io::stdin().is_terminal():
- TTY → interactive ratatui chat (
run_interactive, Unix-only — needs fd
redirection).
- non-TTY →
run_acp_server() (editor launched it over a pipe).
Account verbs (sigit login / logout / whoami) are handled before the split,
since the editor launches sigit login in an embedded terminal.
Protocol flow
Editor Agent
│── initialize ──────────────────────►│ capabilities + auth methods + meta
│◄─ InitializeResponse ───────────────│
│── authenticate ────────────────────►│ (button → verify stored session)
│◄─ AuthenticateResponse ─────────────│
│── session/new (or load / fork) ───►│ cwd, reset history
│◄─ …Response(config_options) ────────│
│◄─ session/update AvailableCommands ─│ advertise slash commands
│── session/setConfigOption ─────────►│ (model picker) → ToolCall progress
│── session/prompt ──────────────────►│ user message (text + resources)
│◄─ session/update (N×) ──────────────│ streaming chunks / tool-call cards
│◄─ PromptResponse(EndTurn) ──────────│
│── session/cancel (notification) ───►│
│── [disconnect] ─────────────────────►│ connect_to future resolves → shutdown
Zed configuration
{
"agent_servers": {
"siGit Code": {
"type": "custom",
"command": "/absolute/path/to/target/release/sigit"
}
}
}
Gotchas
- No
Agent trait to implement — it's a builder. Register handler closures
with .on_receive_request(closure, on_receive_request!()) and finish with
.connect_to(transport). The on_receive_request!() / on_receive_notification!()
macro is mandatory per handler.
cx: ConnectionTo<Client> replaces the mpsc forwarder — send notifications
with cx.send_notification(...) and background tasks with cx.spawn(...).
Don't reintroduce the old channel-based circular-dependency pattern.
Error::internal() doesn't exist — use Error::new(-32603, msg).
- Everything in
agent_client_protocol::schema is #[non_exhaustive] — use
builder methods, never struct literals; add _ => … wildcards when matching.
ByteStreams::new(stdout, stdin) — writer first, reader second. Adapt
tokio stdio with .compat() / .compat_write() (tokio-util).
block_in_place panics in handler/cx.spawn tasks — run mistralrs model
loads on a dedicated std::thread + its own Runtime; signal back via
AtomicBool/oneshot. Never load inside a prompt handler directly.
- Empty
authMethods hangs Zed — always include at least one
AuthMethod::Agent(AuthMethodAgent::new("id", "Name")). Prefer Agent over
Terminal for custom agents (Zed never spawns the terminal for them).
- Never write to stdout except JSON-RPC — log to stderr; in TTY mode siGit
redirects fds to
$TMPDIR/sigit.log. Any stray println! or native library
stdout write corrupts the wire.
- Unstable features gate real types —
unstable_session_fork,
unstable_session_additional_directories, unstable_auth_methods must be on
in Cargo.toml or ForkSessionRequest, additional_directories, and
AuthMethod::Agent won't exist.
- Zed re-fires the last config selection on connect — make
setConfigOption a no-op when the requested model is already active, and
never start a model switch while a startup load is still in flight (GPU OOM).
- Store
SessionId as SessionId, not String, so == is clean.
SetSessionConfigOptionResponse::new(config_options) — the response
carries the rebuilt options so the picker reflects the new current value.
Where to look in the code
Everything ACP lives in src/main.rs:
run_acp_server — builder wiring + transport.
SiGitAgent + handle_* — the handlers.
build_model_config_options / resolve_model_config — picker.
parse_slash / exec_slash_acp — slash commands.
handle_response — the Responder helper.
src/backend.rs holds the InferenceBackend trait (LocalBackend /
OpenAiBackend) used by handle_prompt's tool-calling loop; src/tools.rs
defines the agent tools and execute_tool.