Deep Integration and Practical Use of StreamJsonRpc in HagiCode
Deep Integration and Practical Use of StreamJsonRpc in HagiCode
Section titled “Deep Integration and Practical Use of StreamJsonRpc in HagiCode”This article explains in detail how the HagiCode (formerly PCode) project successfully integrated Microsoft’s StreamJsonRpc communication library to replace its original custom JSON-RPC implementation, while also resolving the technical pain points and architectural challenges encountered during the integration process.
Background
Section titled “Background”StreamJsonRpc is Microsoft’s officially maintained JSON-RPC communication library for .NET and TypeScript, known for its strong type safety, automatic proxy generation, and mature exception handling mechanisms. In the HagiCode project, the team decided to integrate StreamJsonRpc in order to communicate with external AI tools such as iflow CLI and OpenCode CLI through ACP (Agent Communication Protocol), while also eliminating the maintenance cost and potential bugs introduced by the earlier custom JSON-RPC implementation. However, the integration process ran into challenges specific to streaming JSON-RPC, especially when handling proxy target binding and generic parameter recognition.
To address these pain points, we made a bold decision: rebuild the entire build system from the ground up. The impact of that decision may be even bigger than you expect, and I will explain it in detail shortly.
About HagiCode
Section titled “About HagiCode”Let me first introduce the “main project” featured in this article.
If you have run into these frustrations during development:
- Multiple projects and multiple tech stacks make build scripts expensive to maintain
- CI/CD pipeline configuration is cumbersome, and every change sends you back to the docs
- Cross-platform compatibility issues keep surfacing
- You want AI to help you write code, but existing tools are not intelligent enough
Then HagiCode, which we are building, may interest you.
What is HagiCode?
- An AI-driven intelligent coding assistant
- Supports multilingual, cross-platform code generation and optimization
- Includes built-in gamification mechanisms so coding feels less dull
Why mention it here? The StreamJsonRpc integration approach shared in this article is distilled from our practical experience while developing HagiCode. If you find this engineering solution valuable, it probably means our technical taste is pretty solid, and HagiCode itself is worth checking out as well.
Want to learn more?
- GitHub: github.com/HagiCode-org/site (a Star would be appreciated)
- Official website: hagicode.com
- Video demo: www.bilibili.com/video/BV1pirZBuEzq/ (30-minute hands-on demo)
- Installation guide: hagicode.com/installation/docker-compose
- The public beta has started: install now to join the beta
Analysis
Section titled “Analysis”The current project is in a critical stage of ACP protocol integration and is facing the following technical pain points and architectural challenges:
1. Limitations of the Custom Implementation
Section titled “1. Limitations of the Custom Implementation”The original JSON-RPC implementation is located in src/HagiCode.ClaudeHelper/AcpImp/ and includes components such as JsonRpcEndpoint and ClientSideConnection. Maintaining this custom codebase is costly, and it lacks the advanced capabilities of a mature library, such as progress reporting and cancellation support.
2. StreamJsonRpc Integration Obstacles
Section titled “2. StreamJsonRpc Integration Obstacles”When attempting to migrate the existing CallbackProxyTarget pattern to StreamJsonRpc, we found that the _rpc.AddLocalRpcTarget(target) method could not recognize targets created through the proxy pattern. Specifically, StreamJsonRpc could not automatically split properties of the generic type T into RPC method parameters, causing the server side to fail when processing method calls initiated by the client.
3. Confused Architectural Layering
Section titled “3. Confused Architectural Layering”The existing ClientSideConnection mixes the transport layer (WebSocket/Stdio), protocol layer (JSON-RPC), and business layer (ACP Agent interface), leading to unclear responsibilities. It also suffers from missing method bindings in AcpAgentCallbackRpcAdapter.
4. Missing Logs
Section titled “4. Missing Logs”The WebSocket transport layer lacks raw JSON content logging, making it difficult to determine during RPC debugging whether a problem originates from serialization or from the network.
Solution
Section titled “Solution”To address the problems above, we adopted the following systematic solution, optimizing from three dimensions: architectural refactoring, library integration, and enhanced debugging.
1. Fully Migrate to StreamJsonRpc
Section titled “1. Fully Migrate to StreamJsonRpc”Remove Legacy Code
Section titled “Remove Legacy Code”Delete JsonRpcEndpoint.cs, AgentSideConnection.cs, and related custom serialization converters such as JsonRpcMessageJsonConverter.
Integrate the Official Library
Section titled “Integrate the Official Library”Introduce the StreamJsonRpc NuGet package and use its JsonRpc class to handle the core communication logic.
Abstract the Transport Layer
Section titled “Abstract the Transport Layer”Define the IAcpTransport interface to handle both WebSocket and Stdio transport modes in a unified way, ensuring the protocol layer is decoupled from the transport layer.
// Definition of the IAcpTransport interfacepublic interface IAcpTransport{ Task SendAsync(string message, CancellationToken cancellationToken = default); Task<string> ReceiveAsync(CancellationToken cancellationToken = default); Task CloseAsync(CancellationToken cancellationToken = default);}
// WebSocket transport implementationpublic class WebSocketTransport : IAcpTransport{ private readonly WebSocket _webSocket;
public WebSocketTransport(WebSocket webSocket) { _webSocket = webSocket; }
// Implement send and receive methods // ...}
// Stdio transport implementationpublic class StdioTransport : IAcpTransport{ private readonly StreamReader _reader; private readonly StreamWriter _writer;
public StdioTransport(StreamReader reader, StreamWriter writer) { _reader = reader; _writer = writer; }
// Implement send and receive methods // ...}2. Fix Proxy Target Recognition Problems
Section titled “2. Fix Proxy Target Recognition Problems”Analyze CallbackProxyTarget
Section titled “Analyze CallbackProxyTarget”Inspect the existing dynamic proxy generation logic and identify the root cause of why StreamJsonRpc cannot recognize it. In most cases, this happens because the proxy object does not publicly expose the actual method signatures, or it uses parameter types unsupported by StreamJsonRpc.
Refactor Parameter Passing
Section titled “Refactor Parameter Passing”Split generic properties into explicit RPC method parameters. Instead of relying on dynamic properties, define concrete Request/Response DTOs (data transfer objects) so StreamJsonRpc can correctly recognize method signatures through reflection.
// Original generic property approachpublic class CallbackProxyTarget<T>{ public Func<T, Task> Callback { get; set; }}
// Refactored concrete method approachpublic class ReadTextFileRequest{ public string FilePath { get; set; }}
public class ReadTextFileResponse{ public string Content { get; set; }}
public interface IAcpAgentCallback{ Task<ReadTextFileResponse> ReadTextFileAsync(ReadTextFileRequest request); // Other methods...}Use Attach Instead of AddLocalRpcTarget
Section titled “Use Attach Instead of AddLocalRpcTarget”In some complex scenarios, manually proxying the JsonRpc object and handling RpcConnection can be more flexible than directly adding a local target.
3. Implement Method Binding and Stronger Logging
Section titled “3. Implement Method Binding and Stronger Logging”Implement AcpAgentCallbackRpcAdapter
Section titled “Implement AcpAgentCallbackRpcAdapter”Ensure that this component explicitly implements the StreamJsonRpc proxy interface and maps methods defined by the ACP protocol, such as ReadTextFileAsync, to StreamJsonRpc callback handlers.
Integrate Logging
Section titled “Integrate Logging”Intercept and record the raw text of JSON-RPC requests and responses in the WebSocket or Stdio message processing pipeline. Use ILogger to output the raw payload before parsing and after serialization so formatting issues can be diagnosed more easily.
// Transport wrapper with enhanced loggingpublic class LoggingAcpTransport : IAcpTransport{ private readonly IAcpTransport _innerTransport; private readonly ILogger<LoggingAcpTransport> _logger;
public LoggingAcpTransport(IAcpTransport innerTransport, ILogger<LoggingAcpTransport> logger) { _innerTransport = innerTransport; _logger = logger; }
public async Task SendAsync(string message, CancellationToken cancellationToken = default) { _logger.LogTrace("Sending message: {Message}", message); await _innerTransport.SendAsync(message, cancellationToken); }
public async Task<string> ReceiveAsync(CancellationToken cancellationToken = default) { var message = await _innerTransport.ReceiveAsync(cancellationToken); _logger.LogTrace("Received message: {Message}", message); return message; }
public async Task CloseAsync(CancellationToken cancellationToken = default) { _logger.LogDebug("Closing connection"); await _innerTransport.CloseAsync(cancellationToken); }}4. Refactor Architectural Layering
Section titled “4. Refactor Architectural Layering”Transport Layer (AcpRpcClient)
Section titled “Transport Layer (AcpRpcClient)”Wrap the StreamJsonRpc connection and make it responsible for InvokeAsync calls and connection lifecycle management.
public class AcpRpcClient : IDisposable{ private readonly JsonRpc _rpc; private readonly IAcpTransport _transport;
public AcpRpcClient(IAcpTransport transport) { _transport = transport; _rpc = new JsonRpc(new StreamRpcTransport(transport)); _rpc.StartListening(); }
public async Task<TResponse> InvokeAsync<TResponse>(string methodName, object parameters) { return await _rpc.InvokeAsync<TResponse>(methodName, parameters); }
public void Dispose() { _rpc.Dispose(); _transport.Dispose(); }
// StreamRpcTransport is the StreamJsonRpc adapter for IAcpTransport private class StreamRpcTransport : IDuplexPipe { // Implement the IDuplexPipe interface // ... }}Protocol Layer (IAcpAgentClient / IAcpAgentCallback)
Section titled “Protocol Layer (IAcpAgentClient / IAcpAgentCallback)”Define clear client-to-agent and agent-to-client interfaces. Remove the cyclic factory pattern Func<IAcpAgent, IAcpClient> and replace it with dependency injection or direct callback registration.
Practice
Section titled “Practice”Based on StreamJsonRpc best practices and project experience, the following are the key recommendations for implementation:
1. Strongly Typed DTOs Are Better Than Dynamic Objects
Section titled “1. Strongly Typed DTOs Are Better Than Dynamic Objects”The core advantage of StreamJsonRpc lies in strong typing. Do not use dynamic or JObject to pass parameters. Instead, define explicit C# POCO classes as parameters for each RPC method. This not only solves the proxy target recognition issue, but also catches type errors at compile time.
Example: replace the generic properties in CallbackProxyTarget with concrete classes such as ReadTextFileRequest and WriteTextFileRequest.
2. Explicitly Declare Method Names
Section titled “2. Explicitly Declare Method Names”Use the [JsonRpcMethod] attribute to explicitly specify RPC method names instead of relying on default method-name mapping. This prevents invocation failures caused by naming-style differences such as PascalCase versus camelCase.
public interface IAcpAgentCallback{ [JsonRpcMethod("readTextFile")] Task<ReadTextFileResponse> ReadTextFileAsync(ReadTextFileRequest request);
[JsonRpcMethod("writeTextFile")] Task WriteTextFileAsync(WriteTextFileRequest request);}3. Take Advantage of Connection State Callbacks
Section titled “3. Take Advantage of Connection State Callbacks”StreamJsonRpc provides the JsonRpc.ConnectionLost event. You should absolutely listen for this event to handle unexpected process exits or network disconnections, which is more timely than relying only on Orleans Grain failure detection.
_rpc.ConnectionLost += (sender, e) =>{ _logger.LogError("RPC connection lost: {Reason}", e.ToString()); // Handle reconnection logic or notify the user};4. Record Logs by Layer
Section titled “4. Record Logs by Layer”- Trace level: Record the full raw JSON request/response payload.
- Debug level: Record method call stacks and parameter summaries.
- Note: Make sure the logs do not include sensitive Authorization tokens or Base64-encoded large-file content.
5. Handle the Special Nature of Streaming Transport
Section titled “5. Handle the Special Nature of Streaming Transport”StreamJsonRpc natively supports IAsyncEnumerable. When implementing streaming prompt responses for ACP, use IAsyncEnumerable directly instead of custom pagination logic. This can greatly simplify the amount of code needed for streaming processing.
public interface IAcpAgentCallback{ [JsonRpcMethod("streamText")] IAsyncEnumerable<string> StreamTextAsync(StreamTextRequest request);}6. Adapter Pattern
Section titled “6. Adapter Pattern”Keep ACPSession and ClientSideConnection separate. ACPSession should focus on Orleans state management and business logic, such as message enqueueing, and should use the StreamJsonRpc connection object through composition rather than inheritance.
Conclusion
Section titled “Conclusion”By comprehensively integrating StreamJsonRpc, the HagiCode project successfully addressed the high maintenance cost, functional limitations, and architectural layering confusion of the original custom implementation. The key improvements include:
- Replacing dynamic properties with strongly typed DTOs, improving maintainability and reliability
- Implementing transport-layer abstraction and protocol-layer separation, improving architectural clarity
- Strengthening logging capabilities to make communication problems easier to diagnose
- Introducing streaming support to simplify streaming implementation
These improvements provide HagiCode with a more stable and more efficient communication foundation, allowing it to interact better with external AI tools and laying a solid foundation for future feature expansion.
References
Section titled “References”- StreamJsonRpc official documentation: https://learn.microsoft.com/en-us/dotnet/api/microsoft.visualstudio.threading.streamjsonrpc
- ACP (Agent Communication Protocol) specification: https://github.com/microsoft/agentcommunicationprotocol
- HagiCode project: https://github.com/HagiCode-org/site
- Orleans official documentation: https://learn.microsoft.com/en-us/dotnet/orleans
If this article helped you:
- Give it a like so more people can discover it
- Give us a Star on GitHub: github.com/HagiCode-org/site
- Visit the official website to learn more: hagicode.com
- Watch the 30-minute hands-on demo: www.bilibili.com/video/BV1pirZBuEzq/
- Try the one-click installation: hagicode.com/installation/docker-compose
- The public beta has started, and you are welcome to install and try it
Thank you for reading. If you found this article useful, please click the like button below so more people can discover it.
This content was created with AI-assisted collaboration and reviewed by me, and it reflects my own views and position.
- Author: newbe36524
- Article link: https://hagicode.com/blog/2026/01/28/streamjsonrpc-integration-in-hagicode
- Copyright: Unless otherwise stated, all blog articles on this site are licensed under BY-NC-SA. Please cite the source when reposting!