# Architecture
This document explains how `kimi-wire` is structured, how the Kimi Code CLI Wire protocol lifecycle works, and what design trade-offs were made along the way. It is aimed at new contributors and advanced users who want to understand the crate without reading every source file.
---
## Overview
The Wire protocol is a **JSON-RPC 2.0** based **bidirectional** communication channel exposed by `kimi --wire`. Both the client (your code) and the server (the Kimi CLI agent) can initiate requests. The transport is newline-delimited JSON over stdio by default, but the crate abstracts this so that in-memory channels or custom backends work as well.
`kimi-wire` sits in the middle of that stack. It provides:
* **Typed protocol structs** — `Event`, `Request`, `PromptResult`, `DisplayBlock`, `ContentPart`, and so on.
* **A `WireClient` trait** — high-level async methods such as `prompt`, `replay`, `steer`, `set_plan_mode`, and `cancel`.
* **A `Transport` trait** — abstracts reading and writing newline-delimited JSON lines.
* **Transport implementations** — `ChildProcessTransport` (production, spawns `kimi --wire`) and `ChannelTransport` (testing, in-memory).
* **Optional secret redaction** — scrub secrets from JSON wire logs before they reach `tracing` or stderr.
In short: your code talks to a `WireClient`, the `WireClient` talks to a `Transport`, and the `Transport` talks to the Kimi process.
---
## Layered design
```text
+------------------+
|
v
+------------------+
+------------------+
|
v
+------------------+
+------------------+
|
v
+------------------+
```
### `WireClient`
[`WireClient`](https://docs.rs/kimi-wire/latest/kimi_wire/trait.WireClient.html) is the primary user-facing abstraction. It defines:
* **High-level methods** like `prompt`, `replay`, `steer`, `set_plan_mode`, and `cancel`. These build a JSON-RPC request, send it, and wait for the matching response.
* **Lifecycle methods** like `initialize` and `shutdown`.
* **Low-level primitives** like `send_request`, `read_raw_message`, `send_response`, and `send_error` for bidirectional traffic.
Two implementations ship with the crate:
* [`InMemoryWireClient`](https://docs.rs/kimi-wire/latest/kimi_wire/struct.InMemoryWireClient.html) — stores outgoing messages in a `Vec` and lets tests inject incoming [`RawWireMessage`](https://docs.rs/kimi-wire/latest/kimi_wire/protocol/struct.RawWireMessage.html) values. Useful for unit tests.
* [`TransportWireClient<T: Transport>`](https://docs.rs/kimi-wire/latest/kimi_wire/transport/struct.TransportWireClient.html) — bridges the `WireClient` trait to any `Transport`. This is what you use in production.
### `Transport`
[`Transport`](https://docs.rs/kimi-wire/latest/kimi_wire/transport/trait.Transport.html) is a minimal two-method trait:
```rust
pub trait Transport: Send {
fn read_line(&mut self)
-> impl std::future::Future<Output = Result<Option<String>, WireError>> + Send;
fn write_line(&mut self, line: &str)
-> impl std::future::Future<Output = Result<(), WireError>> + Send;
}
```
Its only job is to move newline-terminated JSON strings across some boundary. The `Transport` trait does not know about JSON-RPC, events, or requests — it is purely a byte pipe.
---
## Protocol lifecycle
A typical session follows six phases.
### 1. Spawn / connect
Production code creates a [`ChildProcessTransport`](https://docs.rs/kimi-wire/latest/kimi_wire/transport/struct.ChildProcessTransport.html):
```rust
use kimi_wire::transport::{ChildProcessTransport, TransportWireClient};
let transport = ChildProcessTransport::spawn("kimi", None, None, None).await?;
let mut client = TransportWireClient::new(transport);
```
`ChildProcessTransport::spawn`:
* Builds a `tokio::process::Command` with `--wire`, plus optional `--work-dir`, `--session`, and `--model`.
* Sets `kill_on_drop(true)` on the child so the OS process is terminated if the transport is dropped.
* Retries up to 3 times on `EAGAIN` (raw OS error 26), sleeping 25 ms between attempts.
* Starts a background stderr-reading task. If the `redact` feature is enabled, each stderr line is passed through `scrub_secret_patterns` before being logged via `tracing::warn!`.
For tests, use [`ChannelTransport::pair()`](https://docs.rs/kimi-wire/latest/kimi_wire/transport/struct.ChannelTransport.html) to get two connected in-memory transports.
### 2. Initialize handshake
Before sending prompts, call `initialize`:
```rust
use kimi_wire::{WireClient, protocol::InitializeParams};
let result = client.initialize(InitializeParams::new("1.10")).await?;
```
`TransportWireClient::initialize` sends a JSON-RPC request with `method: "initialize"` and waits for a response. If the server responds with JSON-RPC error `-32601` ([`METHOD_NOT_FOUND`](https://docs.rs/kimi-wire/latest/kimi_wire/protocol/constant.METHOD_NOT_FOUND.html)), the client transparently falls back to legacy mode:
```rust
pub const WIRE_PROTOCOL_LEGACY_VERSION: &str = "legacy/no-handshake";
```
The handshake is still marked as done, and all subsequent high-level methods work normally. This lets `kimi-wire` talk to older Kimi CLI binaries that do not implement the `initialize` method.
### 3. Client → server methods
After initialization, the client can call any of the high-level methods. Each builds a JSON-RPC request, sends it, and blocks until the matching response arrives:
| `prompt` | `prompt` | Start a new turn with user input and wait for it to finish. |
| `replay` | `replay` | Replay events and requests from the current session. |
| `steer` | `steer` | Inject additional user input into the current turn. |
| `set_plan_mode` | `set_plan_mode` | Enable or disable plan mode. |
| `cancel` | `cancel` | Cancel the current turn. |
All of these internally use `next_id`, `send_request`, and `read_response`.
### 4. Server → client events
The agent emits **events** as JSON-RPC **notifications** (`method: "event"`, no `id`). Events do not require a response. You read them by calling `read_raw_message` and deserializing the payload:
```rust
use kimi_wire::{WireClient, protocol::{Event, RawWireMessage}};
loop {
let raw: RawWireMessage = client.read_raw_message().await?;
if raw.method.as_deref() == Some("event") {
let params = raw.params.ok_or_else(|| WireError::InvalidPayloadType)?;
let event: Event = serde_json::from_value(params)?;
match event {
Event::TurnBegin { user_input } => { /* ... */ }
Event::TurnEnd => { /* ... */ }
Event::ToolCall { .. } => { /* ... */ }
_ => {}
}
}
}
```
Events follow the envelope format `{"type": "TurnBegin", "payload": {...}}`. The [`Event`](https://docs.rs/kimi-wire/latest/kimi_wire/protocol/enum.Event.html) enum has variants such as `TurnBegin`, `StepBegin`, `StepRetry`, `ToolCall`, `StatusUpdate`, `BtwBegin`, `BtwEnd`, and others.
### 5. Server → client requests
This is the most important part of the bidirectional flow. The agent can send **requests** to the client (for example, asking for tool approval or executing a tool). These arrive as JSON-RPC **requests** — they have an `id` and expect a response.
The method name on the wire is `"request"`. The payload is a [`Request`](https://docs.rs/kimi-wire/latest/kimi_wire/protocol/enum.Request.html) enum variant:
* `ApprovalRequest` — ask the user for permission before running a tool.
* `ToolCallRequest` — execute a tool and return the result.
* `QuestionRequest` — ask the user an interactive question.
* `HookRequest` — trigger a subscribed hook.
A minimal read-deserialize-respond loop looks like this:
```rust
use kimi_wire::{
WireClient,
protocol::{
Request, ApprovalResponse, ToolCallResponse,
QuestionResponse, HookResponse, RawWireMessage,
},
};
let raw: RawWireMessage = client.read_raw_message().await?;
// Only handle server-to-client requests.
if raw.method.as_deref() == Some("request") {
let id = raw.id.clone().ok_or_else(|| WireError::Internal("request missing id".to_string()))?;
let params = raw.params.ok_or_else(|| WireError::InvalidPayloadType)?;
let request: Request = serde_json::from_value(params)?;
match request {
Request::ApprovalRequest(req) => {
let response = ApprovalResponse {
request_id: req.id,
response: kimi_wire::protocol::ApprovalResponseKind::Approve,
feedback: None,
};
client.send_response(&id, &response).await?;
}
Request::ToolCallRequest(req) => {
// ... run the tool ...
let response = ToolCallResponse {
tool_call_id: req.id,
return_value: todo!(),
};
client.send_response(&id, &response).await?;
}
Request::QuestionRequest(req) => {
// ... ask the user ...
let response = QuestionResponse {
request_id: req.id,
answers: todo!(),
};
client.send_response(&id, &response).await?;
}
Request::HookRequest(req) => {
// ... handle hook ...
let response = HookResponse {
request_id: req.id,
action: todo!(),
reason: "handled".to_string(),
};
client.send_response(&id, &response).await?;
}
}
}
```
If you cannot fulfill the request, use `send_error` instead:
```rust
client.send_error(&id, -32600, "parse error").await?;
```
For canonical examples of transport-level request/response handling, see [`tests/transport_test.rs`](../tests/transport_test.rs).
### 6. Shutdown
Call `client.shutdown().await` to close the session gracefully. In addition, `ChildProcessTransport` kills the child process on `Drop` as a fallback, so dropping the client will not leave a zombie `kimi` process.
---
## Out-of-order responses
JSON-RPC over a single stdio pipe is inherently ordered, but responses can arrive out of order when the server interleaves notifications, events, and responses to concurrent requests.
[`TransportWireClient::read_response(id)`](https://docs.rs/kimi-wire/latest/kimi_wire/transport/struct.TransportWireClient.html) solves this with an internal `pending_messages` buffer (`VecDeque<RawWireMessage>`):
1. It first scans the buffer for a message whose `id` matches `expected_id`.
2. If the expected message is not buffered, it reads new lines from the transport.
3. Any message with a non-matching `id` is pushed into `pending_messages`.
4. Subsequent calls drain the buffer before touching the transport again.
This decouples *logical* response ordering from *arrival* ordering, which matters when bidirectional traffic (events + requests) is mixed with client-initiated method calls.
### Maximum line length
The constant [`MAX_WIRE_LINE_LENGTH`](https://docs.rs/kimi-wire/latest/kimi_wire/transport/constant.MAX_WIRE_LINE_LENGTH.html) caps each JSON line at **16 MiB**:
```rust
pub const MAX_WIRE_LINE_LENGTH: usize = 16 * 1024 * 1024;
```
Without a hard cap, a peer that never emits a newline could drive `read_line` to allocate until OOM. `ChildProcessTransport` configures its `LinesCodec` with this limit, and custom transports should enforce a similar bound.
---
## Transport implementations
### `ChildProcessTransport`
The production transport. Spawns a `kimi` child process with `--wire`, pipes stdin/stdout, and optionally captures/redacts stderr. Gated behind the `process` feature (enabled by default).
### `ChannelTransport`
A test transport backed by `tokio::sync::mpsc::unbounded_channel`. `ChannelTransport::pair()` returns two connected transports so that one side acts as the client and the other as the mock server.
### Writing a custom transport
Implement the two-method `Transport` trait:
```rust
use kimi_wire::transport::Transport;
use kimi_wire::WireError;
pub struct MyTransport;
impl Transport for MyTransport {
async fn read_line(&mut self) -> Result<Option<String>, WireError> {
// Return the next newline-terminated JSON line, or None on EOF.
todo!()
}
async fn write_line(&mut self, line: &str) -> Result<(), WireError> {
// Write `line` followed by `\n` and flush.
todo!()
}
}
```
Then use it with `TransportWireClient::new(my_transport)`.
### `InMemoryWireClient` (testing)
For unit tests that do not need a real transport at all, [`InMemoryWireClient`](https://docs.rs/kimi-wire/latest/kimi_wire/struct.InMemoryWireClient.html) implements `WireClient` directly. It records every sent request in an internal `Vec` and lets tests inject raw responses via `inject`. This is the fastest way to test protocol logic without spawning processes or managing channels.
---
## Object safety
`WireClient` uses **return-position `impl Trait` in trait** (RPITIT, stable since Rust 1.75) plus generic methods on `<Params: Serialize>`. This means `WireClient` is **not object-safe**: `Box<dyn WireClient>` does not compile.
If you need runtime polymorphism — for example, to switch between a mock client and a real one in tests vs. production — use enum dispatch:
```rust
use kimi_wire::{InMemoryWireClient, WireClient};
use kimi_wire::transport::{ChildProcessTransport, TransportWireClient};
enum AnyClient {
InMemory(InMemoryWireClient),
Process(TransportWireClient<ChildProcessTransport>),
}
impl AnyClient {
async fn prompt(&mut self, input: &str) -> Result<kimi_wire::protocol::PromptResult, kimi_wire::WireError> {
match self {
AnyClient::InMemory(c) => c.prompt(input).await,
AnyClient::Process(c) => c.prompt(input).await,
}
}
// Forward other methods similarly ...
}
```
This trade-off was made for zero-cost generic dispatch. Most users have a known transport at compile time, so the ergonomic cost of enum dispatch is small, while the performance win (no vtable indirection, no boxing) is free.
---
## Feature flags
| `process` | ✅ | Enables `ChildProcessTransport` + `TransportWireClient`. Pulls in `tokio-util`, `tokio-stream`, `tracing`. |
| `redact` | ✅ | Enables `redact_secrets` / `scrub_secret_patterns`. Pulls in `regex`. |
If you need a minimal dependency tree (for example, in a constrained environment where `regex` or `tokio-util` are undesirable), disable defaults and opt in individually:
```toml
[dependencies]
kimi-wire = { version = "0.1", default-features = false, features = ["process"] }
```
### `redact` caveat
Redaction is **best-effort**. It is key-name + value-pattern based and cannot catch every secret shape. Do not rely on it for security-critical filtering without additional review.
**Covered value patterns:**
* GitHub PAT / OAuth / refresh tokens (`ghp_…`, `gho_…`, etc.)
* AWS access key IDs (`AKIA…`)
* Slack tokens (`xoxb-…`, `xoxp-…`, etc.)
* Stripe secret keys (`sk_live_…`, `sk_test_…`)
* Generic Bearer-token fragments (`Bearer <alphanum>…`)
* PEM private key blocks (`-----BEGIN … PRIVATE KEY-----`)
**Sensitive key names (value is fully redacted regardless of shape):**
`api_key`, `apikey`, `token`, `authorization`, `password`, `secret`, and any key ending in `_token`, `-token`, `_secret`, `-secret`, or containing `authorization`.
---
## Error handling
`WireError` is a typed enum with variants such as `StreamClosed`, `Io`, `JsonParse`, `RequestFailed`, `Timeout`, and `SpawnFailed`.
> `WireError` implements `Clone + PartialEq` for test ergonomics, which prevents using `#[source]` to preserve the underlying `std::io::Error` / `serde_json::Error` cause chain. Cause-chain content is collapsed into the error's display message at the conversion boundary. Callers receive actionable text, not a chain they can downcast.
This is the ADR recorded in `src/error.rs`. The practical consequence is that you `match` on `WireError` variants rather than downcasting `dyn Error` sources:
```rust
match err {
WireError::StreamClosed => { /* reconnect */ }
WireError::RequestFailed { code, message } => { /* propagate */ }
WireError::Io(msg) => { /* log */ }
_ => { /* other */ }
}
```
`anyhow` is banned from the public API. Every public function that can fail returns a specific `WireError` variant.
---
## Wire protocol version
The crate targets **Wire protocol version 1.10** (`WIRE_PROTOCOL_VERSION`). Forward-compatible additions to the protocol are handled via `Option<T>` fields with `#[serde(default, skip_serializing_if = "Option::is_none")]`. Servers responding with `METHOD_NOT_FOUND` (`-32601`) to `initialize` are treated as legacy (`WIRE_PROTOCOL_LEGACY_VERSION = "legacy/no-handshake"`) and the client continues operating without a formal handshake.
---
## Further reading
* [`AGENTS.md`](../AGENTS.md) — contributor conventions and hard constraints (MSRV, safety rules, error handling doctrine).
* [`CHANGELOG.md`](../CHANGELOG.md) — version history and breaking changes.
* [Official Wire protocol documentation](https://www.kimi.com/code/docs/en/kimi-code-cli/customization/wire-protocol.html) — the canonical spec maintained by Moonshot AI.