Skip to content

C#

5 posts with the tag “C#”

Practical Guide to Integrating CodeBuddy CLI into a C# Backend

Practical Guide to Integrating CodeBuddy CLI into a C# Backend

Section titled “Practical Guide to Integrating CodeBuddy CLI into a C# Backend”

This article walks through a complete approach to integrating CodeBuddy CLI into a C# backend project so you can deliver AI coding assistant capabilities end to end.

In modern AI coding assistant development, a single AI Provider often cannot satisfy complex and changing development scenarios. HagiCode, as a multifunctional AI coding assistant, needs to support multiple AI Providers to deliver a better user experience. Users should have enough freedom to choose. In early 2026, the project faced a key decision: how to restore CodeBuddy ACP (Agent Communication Protocol) integration capabilities in the C# backend.

The project had previously implemented CodeBuddy integration, but the related code was removed during a refactor. There is not much to complain about there; during iterative development, something always gets left behind. The goal of this technical solution was to fully restore that capability and improve the architecture so it would be more robust and maintainable.

If you are also considering connecting multiple AI coding assistants to your own project, the approach below may give you some ideas. It reflects lessons we summarized after stepping into plenty of pitfalls, and maybe it can help you avoid a few detours.

The approach shared in this article comes from our practical experience in the HagiCode project. HagiCode is an open-source AI coding assistant project that supports multiple AI Providers and cross-platform operation. To satisfy different user preferences, we need to switch flexibly among different AI coding assistants, which is exactly why we built the CodeBuddy integration described here.

HagiCode uses a modular design, with AI Providers implemented as pluggable components. This architecture lets us add new AI support easily without affecting existing features. When a design is done well up front, it saves a lot of trouble later. If you are interested in our technical architecture, you can view the full source code on GitHub.

The integration between C# and CodeBuddy uses a clear layered architecture. This design makes responsibilities explicit and makes long-term maintenance much easier:

┌─────────────────────────────────────────────┐
│ Provider Contract Layer │
│ AIProviderType enum + extension methods │
├─────────────────────────────────────────────┤
│ Provider Factory Layer │
│ AIProviderFactory dependency injection factory │
├─────────────────────────────────────────────┤
│ Provider Implementation Layer │
│ CodebuddyCliProvider concrete implementation │
├─────────────────────────────────────────────┤
│ ACP Infrastructure Layer │
│ ACPSessionManager / StdioAcpTransport │
│ AcpRpcClient / AcpAgentClient │
└─────────────────────────────────────────────┘

What are the benefits of this layering? Put simply, each layer stays out of the others’ way. If we later want to change the communication mechanism, for example from stdio to WebSocket, we only need to modify the bottom layer, and the business logic above it stays untouched. Nobody wants a communication change to ripple through the entire codebase.

The Provider contract layer is the foundation of the entire architecture. We define the AIProviderType enum, where CodebuddyCli = 3 is used as the enum value, and implement bidirectional mapping between strings and enums through extension methods. That allows strings in configuration files to be converted conveniently into enums, and enums to be converted back to strings for debugging output.

The Provider factory layer is responsible for creating the corresponding Provider instance based on configuration. It uses .NET dependency injection together with ActivatorUtilities.CreateInstance for dynamic creation. The advantage of the factory pattern is that when adding a new Provider, you only need to add the creation logic instead of modifying existing code.

The Provider implementation layer is where the actual work happens. CodebuddyCliProvider implements the IAIProvider interface and provides two invocation modes: ExecuteAsync for non-streaming calls and StreamAsync for streaming calls.

The ACP infrastructure layer provides the communication foundation underneath. This layer handles all protocol details, including process management, message serialization, and response parsing. It is the foundation that keeps everything above it stable.

CodeBuddy uses Stdio (standard input/output) to communicate with external processes. The startup command is simple:

Terminal window
codebuddy --acp

After that, JSON-RPC messages are exchanged through standard input and output. This approach has several advantages:

  1. Fast startup: local process communication avoids network latency
  2. Simple configuration: you only need to specify the executable path
  3. Environment isolation: each session runs in an independent process, so they do not affect one another

Environment variable injection is supported during communication. Common examples include:

  • CODEBUDDY_API_KEY: API key authentication
  • CODEBUDDY_INTERNET_ENVIRONMENT: network environment configuration

As with communication between people, it helps to choose a convenient channel first.

ACP is based on JSON-RPC 2.0. The message format looks roughly like this:

// Request message
{
"jsonrpc": "2.0",
"id": 1,
"method": "agent/prompt",
"params": {
"prompt": "Help me write a sorting algorithm",
"sessionId": "session-123"
}
}
// Response message
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": "Here is the AI response..."
}
}

In the real implementation, we encapsulate all of these protocol details so the upper business layer only needs to care about the prompt and response.

First, restore the CodeBuddy type in the enum file:

PCode.Models/AIProviderType.cs
public enum AIProviderType
{
ClaudeCodeCli = 0,
CodexCli = 1,
GitHubCopilot = 2,
CodebuddyCli = 3, // Restore this enum value
OpenCodeCli = 4,
IFlowCli = 5,
}

Then add string mapping in the extension methods so the configuration file can specify the Provider by string:

AIProviderTypeExtensions.cs
private static readonly Dictionary<string, AIProviderType> _typeMap = new(
StringComparer.OrdinalIgnoreCase)
{
["CodebuddyCli"] = AIProviderType.CodebuddyCli,
["Codebuddy"] = AIProviderType.CodebuddyCli,
["codebuddy"] = AIProviderType.CodebuddyCli,
// ... Mappings for other providers
};

Add a CodeBuddy creation branch in the factory class:

AIProviderFactory.cs
private IAIProvider? CreateProvider(AIProviderType providerType, ProviderConfiguration config)
{
return providerType switch
{
AIProviderType.CodebuddyCli =>
ActivatorUtilities.CreateInstance<CodebuddyCliProvider>(
_serviceProvider,
Options.Create(config)),
// ... Other providers
_ => throw new NotSupportedException($"Provider {providerType} not supported")
};
}

This uses dependency injection through ActivatorUtilities, which automatically handles constructor parameter injection and is very convenient.

Below is the core implementation of CodebuddyCliProvider, covering both streaming and non-streaming invocation modes:

public class CodebuddyCliProvider : IAIProvider
{
private readonly ILogger<CodebuddyCliProvider> _logger;
private readonly IACPSessionManager _sessionManager;
private readonly ProviderConfiguration _config;
public string Name => "CodebuddyCli";
public bool SupportsStreaming => true;
public ProviderCapabilities Capabilities { get; }
public CodebuddyCliProvider(
ILogger<CodebuddyCliProvider> logger,
IACPSessionManager sessionManager,
IOptions<ProviderConfiguration> config)
{
_logger = logger;
_sessionManager = sessionManager;
_config = config.Value;
// Define the capabilities of the current Provider
Capabilities = new ProviderCapabilities
{
SupportsStreaming = true,
SupportsTools = true,
SupportsSystemMessages = true,
SupportsArtifacts = false,
MaxTokens = 8192
};
}
// Non-streaming call: return all results together after completion
public async Task<AIResponse> ExecuteAsync(
AIRequest request,
CancellationToken cancellationToken = default)
{
// Create an independent session for the request
var session = await _sessionManager.CreateSessionAsync(
"CodebuddyCli",
request.WorkingDirectory,
cancellationToken,
request.SessionId);
try
{
var fullPrompt = BuildPrompt(request);
await session.SendPromptAsync(fullPrompt, cancellationToken);
var responseBuilder = new StringBuilder();
var toolCalls = new List<AIToolCall>();
// Collect all response chunks
await foreach (var chunk in StreamFromSession(session, cancellationToken))
{
if (!string.IsNullOrEmpty(chunk.Content))
{
responseBuilder.Append(chunk.Content);
}
// Handle tool calls...
}
return new AIResponse
{
Content = AIResultContentSanitizer.SanitizeResultContent(
responseBuilder.ToString()),
ToolCalls = toolCalls,
Provider = Name,
Model = string.Empty
};
}
finally
{
// Release session resources
await session.DisposeAsync();
}
}
// Streaming call: return response chunks in real time
public async IAsyncEnumerable<AIStreamingChunk> StreamAsync(
AIRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var session = await _sessionManager.CreateSessionAsync(
"CodebuddyCli",
request.WorkingDirectory,
cancellationToken);
try
{
var fullPrompt = BuildPrompt(request);
await session.SendPromptAsync(fullPrompt, cancellationToken);
await foreach (var chunk in StreamFromSession(session, cancellationToken))
{
yield return chunk;
}
}
finally
{
await session.DisposeAsync();
}
}
private async IAsyncEnumerable<AIStreamingChunk> StreamFromSession(
IACPSession session,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
// Iterate through all updates in the session
await foreach (var notification in session.ReceiveUpdatesAsync(cancellationToken))
{
switch (notification.Update)
{
case AgentMessageChunkSessionUpdate agentMessage:
// Handle text content chunks
if (agentMessage.Content is AcpImp.TextContentBlock textContent)
{
yield return new AIStreamingChunk
{
Content = textContent.Text,
Type = StreamingChunkType.ContentDelta,
IsComplete = false
};
}
break;
case ToolCallSessionUpdate toolCall:
// Handle tool calls
yield return new AIStreamingChunk
{
Content = string.Empty,
Type = StreamingChunkType.ToolCallDelta,
ToolCallDelta = new AIToolCallDelta
{
Id = toolCall.ToolCallId,
Name = toolCall.Kind.ToString(),
Arguments = toolCall.RawInput?.ToString()
}
};
break;
case AcpImp.PromptCompletedSessionUpdate:
// Response complete
yield break;
}
}
}
// Build the full prompt
private string BuildPrompt(AIRequest request, string? embeddedCommandPrompt = null)
{
var sb = new StringBuilder();
// Embedded command prompt, if present
if (!string.IsNullOrEmpty(embeddedCommandPrompt))
{
sb.AppendLine(embeddedCommandPrompt);
sb.AppendLine();
}
// System message
if (!string.IsNullOrEmpty(request.SystemMessage))
{
sb.AppendLine(request.SystemMessage);
sb.AppendLine();
}
// User prompt
sb.Append(request.Prompt);
return sb.ToString();
}
}

There are several key points in this code:

  1. Session management: each request creates an independent session and releases resources after the request completes. This is a lesson learned through trial and error. If session reuse is not handled well, state pollution appears easily.

  2. Streaming processing: IAsyncEnumerable allows the response to be returned while it is still being generated, instead of waiting for all content to finish. This is especially important for long-text scenarios and significantly improves the user experience.

  3. Tool calls: CodeBuddy supports tool calling (Function Calling), handled through ToolCallSessionUpdate. This capability is critical for complex code editing tasks.

  4. Content filtering: AIResultContentSanitizer is used to filter Think block content and keep the output clean.

Add the related services during module registration:

PCodeClaudeHelperModule.cs
public void ConfigureModule(IServiceCollection context)
{
// Register Provider
context.Services.AddTransient<CodebuddyCliProvider>();
// Register ACP infrastructure
context.Services.AddSingleton<IACPSessionManager, ACPSessionManager>();
context.Services.AddSingleton<IAcpPlatformConfigurationResolver, AcpPlatformConfigurationResolver>();
context.Services.AddSingleton<IAIRequestToAcpMapper, AIRequestToAcpMapper>();
context.Services.AddSingleton<IAcpToAIResponseMapper, AcpToAIResponseMapper>();
}

Add CodeBuddy-related configuration to appsettings.json:

AI:
# Default Provider to use
DefaultProvider: "CodebuddyCli"
# Provider configuration
Providers:
CodebuddyCli:
Type: "CodebuddyCli"
WorkingDirectory: "C:/projects/my-app"
ExecutablePath: "C:/tools/codebuddy.cmd"
# Platform-specific configuration
PlatformConfigurations:
CodebuddyCli:
ExecutablePath: "C:/tools/codebuddy.cmd"
Arguments: "--acp"
StartupTimeoutMs: 5000
EnvironmentVariables:
CODEBUDDY_API_KEY: "${CODEBUDDY_API_KEY}"
CODEBUDDY_INTERNET_ENVIRONMENT: "production"

The corresponding configuration model definition:

public class CodebuddyPlatformConfiguration : IAcpPlatformConfiguration
{
public string ProviderName => "CodebuddyCli";
public AcpTransportType TransportType => AcpTransportType.Stdio;
public string ExecutablePath { get; set; } = "codebuddy";
public string Arguments { get; set; } = "--acp";
public int StartupTimeoutMs { get; set; } = 5000;
public Dictionary<string, string?>? EnvironmentVariables { get; set; }
}

We ran into several typical pitfalls during implementation, and sharing them here may help others avoid the same detours:

  1. Session leak issue: at first, sessions were not released correctly, which exhausted process resources. The solution was to use try-finally to ensure resources are released for every request.

  2. Environment variable passing: Windows and Linux use different environment variable syntax, so we later standardized on Dictionary<string, string?> to handle this.

  3. Timeout configuration: CLI startup takes time, so we set a 5-second startup timeout to avoid fast request failures.

  4. Encoding issues: on Windows, the default encoding may cause garbled Chinese text, so UTF-8 encoding is explicitly specified when starting the process.

  1. Session pool: for frequent short requests, consider implementing a session pool to reuse processes
  2. Connection cache: the factory class already supports caching Provider instances
  3. Async first: use asynchronous programming throughout to avoid blocking threads

Performance is always worth optimizing. The longer users wait, the worse the experience becomes.

This article introduced a complete solution for integrating CodeBuddy CLI into a C# backend, covering the entire process from architecture design to concrete implementation. Through a layered architecture, we separate protocol details from business logic, making the code clearer and easier to maintain.

Key takeaways:

  • Use a layered architecture with a Provider contract layer, factory layer, implementation layer, and infrastructure layer
  • Use JSON-RPC over Stdio for inter-process communication
  • Implement flexible configuration and extensibility through dependency injection
  • Provide both streaming and non-streaming invocation modes

This approach is not only suitable for CodeBuddy; adding new AI Providers can follow the same pattern. If you are also building a similar multi-AI-Provider integration, I hope this article gives you a useful reference.



If this article helped you:

From TypeScript to C#: Cross-Language Porting Practice for the Codex SDK

From TypeScript to C#: Cross-Language Porting Practice for the Codex SDK

Section titled “From TypeScript to C#: Cross-Language Porting Practice for the Codex SDK”

Put simply, this article is also a bit of a baby of ours: it records the full process of porting the official TypeScript Codex SDK to C#. Calling it a “port” almost makes it sound too easy - it was more like a long adventure, because these two languages have very different personalities, and we had to find a way to make them cooperate.

Codex is the AI Agent CLI tool released by OpenAI, and it is genuinely powerful. The official team provides a TypeScript SDK in the @openai/codex package. It interacts with the Codex CLI by calling the codex exec --experimental-json command and parsing a JSONL event stream.

The problem is that in the HagiCode project, we need to use it in a pure .NET environment - specifically in C# backend services and desktop applications. We could not reasonably introduce a Node.js runtime into a .NET project just to call a CLI tool. That would be far too cumbersome.

So we were left with two choices: maintain a complex Node.js bridge layer, or build a native C# SDK ourselves.

We chose the latter.

This article also comes directly from our hands-on experience in the HagiCode project. HagiCode is an open-source AI coding assistant project. In plain terms, it means maintaining multiple components at once: a VSCode extension on the frontend, AI services on the backend, and a cross-platform desktop client. That multi-language, multi-platform complexity is exactly why we needed a native C# SDK - we really did not want to run Node.js inside a .NET project.

If you find this article helpful, feel free to give us a star on GitHub: github.com/HagiCode-org/site. You can also visit the official website to learn more: hagicode.com. It is always encouraging when an open-source project receives support.

Before translating code one-to-one, we first had to understand the architectural design of both SDKs. You have to understand both sides before you can port them well.

The core architecture of the TypeScript SDK looks like this:

Codex (entry class)
└── CodexExec (executor, manages child processes)
└── Thread (conversation thread)
├── run() / runStreamed() (synchronous/asynchronous execution)
└── event stream parsing

The C# SDK keeps the same architectural layering, but adapts the implementation details. The overall idea is straightforward: preserve API consistency while fully leveraging C# language features in the implementation.

This is the most fundamental and also the most important part of the work. If the foundation is weak, everything that follows becomes harder.

TypeScript’s type system is more flexible than C#‘s, and that is simply a fact. We needed to find an appropriate mapping strategy:

TypeScriptC#Notes
interface / typerecordC# uses record for immutable data structures
string | nullstring?Nullable reference type
boolean | undefinedbool?Nullable Boolean
AsyncGeneratorIAsyncEnumerableAsync iterator

The event type system is a typical example. TypeScript uses union types to define events:

export type ThreadEvent =
| ThreadStartedEvent
| TurnStartedEvent
| TurnCompletedEvent
| ...

In C#, we use an inheritance hierarchy and pattern matching to achieve a similar effect:

public abstract record ThreadEvent(string Type);
public sealed record ThreadStartedEvent(string ThreadId) : ThreadEvent("thread.started");
public sealed record TurnStartedEvent() : ThreadEvent("turn.started");
public sealed record TurnCompletedEvent(Usage Usage) : ThreadEvent("turn.completed");
// ...

We chose record instead of class because event objects should be immutable, which matches the intent behind using plain objects in TypeScript. The sealed keyword also prevents additional inheritance and gives the compiler room to optimize.

Event parsing is the core of the entire SDK, because it determines whether we can correctly understand every message returned by the Codex CLI. If parsing is wrong, everything after that is wasted effort.

The TypeScript version uses JSON.parse() to parse each line of JSON:

export function parseEvent(line: string): ThreadEvent {
const data = JSON.parse(line);
// Handle different event types...
}

The C# version uses System.Text.Json.JsonDocument instead:

public static ThreadEvent Parse(string line)
{
using var document = JsonDocument.Parse(line);
var root = document.RootElement;
var type = GetRequiredString(root, "type", "event.type");
return type switch
{
"thread.started" => new ThreadStartedEvent(GetRequiredString(root, "thread_id", ...)),
"turn.started" => new TurnStartedEvent(),
"turn.completed" => new TurnCompletedEvent(ParseUsage(...)),
// ...
_ => new UnknownThreadEvent(type, root.Clone()),
};
}

There is one small but important trick here: root.Clone() is required, because elements from JsonDocument become invalid after the document is disposed. We need to retain a copy for unknown event types. That is simply one of the differences between C# JSON handling and JavaScript.

This is where the two SDKs differ the most. Node.js and .NET have different runtime conventions, so the implementation has to adapt.

TypeScript uses Node.js’s spawn() function:

const child = spawn(this.executablePath, commandArgs, { env, signal });

C# uses .NET’s System.Diagnostics.Process:

using var process = new Process { StartInfo = startInfo };
process.Start();
// stdin/stdout/stderr must be managed manually

More specifically, the C# version needs to configure the process like this:

var startInfo = new ProcessStartInfo
{
FileName = _executablePath,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};

The biggest difference is the cancellation mechanism. TypeScript uses AbortSignal, which is part of the Web API and very convenient to work with:

const child = spawn(cmd, args, { signal: cancellationSignal });

C# uses CancellationToken instead:

public async IAsyncEnumerable<string> RunAsync(
CodexExecArgs args,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Check cancellation status inside the loop
while (!cancellationToken.IsCancellationRequested)
{
// Process output...
}
// Terminate the process when cancellation is requested
if (cancellationToken.IsCancellationRequested)
{
try { process.Kill(entireProcessTree: true); } catch { }
}
}

At a high level, this is just another example of the difference between the Web API ecosystem and the .NET ecosystem.

Both SDKs implement the logic that converts JSON configuration into TOML configuration, because the Codex CLI accepts configuration overrides in TOML format. This part must remain completely consistent, otherwise the same configuration will behave differently in the two SDKs.

That is the kind of detail you cannot compromise on. Success or failure often comes down to details like this.

We created the following project structure:

CodexSdk/
├── CodexSdk.csproj
├── Codex.cs # Entry class
├── CodexThread.cs # Conversation thread
├── CodexExec.cs # Executor
├── Events.cs # Event type definitions
├── Items.cs # Item type definitions
├── EventParser.cs # Event parser
├── OutputSchemaTempFile.cs # Temporary file management
└── ...

It is a fairly clean structure, and that helped a lot during the port.

The basic usage remains consistent with the TypeScript SDK:

using CodexSdk;
// Create a Codex instance
var codex = new Codex();
var thread = codex.StartThread();
// Execute a query
var result = await thread.RunAsync("Summarize this repository.");
Console.WriteLine(result.FinalResponse);

Streaming event handling takes advantage of C# pattern matching:

await foreach (var @event in thread.RunStreamedAsync("Analyze the code."))
{
switch (@event)
{
case ItemCompletedEvent itemCompleted
when itemCompleted.Item is AgentMessageItem msg:
Console.WriteLine($"Assistant: {msg.Text}");
break;
case TurnCompletedEvent completed:
Console.WriteLine($"Tokens: in={completed.Usage.InputTokens}");
break;
case CommandExecutionItem command:
Console.WriteLine($"Command: {command.Command}");
break;
}
}

During implementation, we collected several practical lessons:

  1. Process management: The C# version must manage the full process lifecycle manually, including process termination during cancellation. Use Kill(entireProcessTree: true) to make sure child processes are also cleaned up.

  2. Error handling: We use InvalidOperationException to throw parsing errors, keeping the error handling style similar to the TypeScript SDK.

  3. Resource cleanup: OutputSchemaTempFile implements IAsyncDisposable to ensure temporary files are cleaned up correctly.

  4. Environment variables: The C# version supports fully overriding process environment variables through CodexOptions.Env. It is a small feature, but a very practical one.

  5. Platform differences: The C# version does not include the TypeScript version’s logic for automatically locating binaries inside npm packages. Since .NET projects typically do not depend on npm, the path to the codex executable must be specified via the CODEX_EXECUTABLE environment variable or CodexPathOverride.

Porting a mature TypeScript SDK to C# is not just a matter of syntax conversion - it also requires understanding the design philosophies of both languages. TypeScript’s flexibility and JavaScript ecosystem features such as AbortSignal need appropriate counterparts in C#.

The key takeaway is this: maintaining API consistency matters more than maintaining implementation-level consistency. Users care about whether the interface is easy to use, not whether the internal implementation is identical. That sounds simple, but making those trade-offs takes judgment.

If you are working on a similar cross-language port, our experience is to fully understand the original SDK architecture first, then translate it module by module, and finally use a complete test suite to ensure behavioral consistency. This kind of work cannot be rushed.

Everything will work out in the end.



If this article helped you:


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, reviewed by the author, and reflects the author’s own views and position.

AI Compose Commit: Using AI to Intelligently Refactor the Git Commit Workflow

AI Compose Commit: Using AI to Intelligently Refactor the Git Commit Workflow

Section titled “AI Compose Commit: Using AI to Intelligently Refactor the Git Commit Workflow”

In the software development process, committing code is a routine task every programmer faces every day. But have you ever run into this situation: at the end of a workday, you open Git, see dozens of unstaged modified files, and have no idea how to organize them into sensible commits?

The traditional approach is to manually stage files in batches, commit them one by one, and write commit messages. This process is both time-consuming and error-prone. We often waste quite a bit of time on this, and after all, nobody wants to worry about these tedious chores late at night when they are already tired.

In the HagiCode project, we introduced a new feature - AI Compose Commit - designed to completely transform this workflow. By using AI to intelligently analyze all uncommitted changes in the working tree, it automatically groups them into multiple logical commits and performs standards-compliant commit operations. In this article, we will take a deep dive into the implementation principles, technical architecture, and the challenges and solutions we encountered in practice.

The approach shared in this article comes from our practical experience in the HagiCode project.

As a version control system, Git gives developers powerful code management capabilities. But in real-world usage, committing often becomes a bottleneck in the development workflow:

  1. Manual grouping is time-consuming: When there are many file changes, developers need to inspect each file one by one and decide which changes belong to the same feature. That takes a lot of mental effort.
  2. Inconsistent commit message quality: Writing commit messages that follow the Conventional Commits specification requires experience and skill, and beginners often produce non-standard commits.
  3. Complex multi-repository management: In a monorepo environment, switching between different repositories adds operational complexity.
  4. Interrupted workflow: Committing code interrupts your train of thought and hurts coding efficiency.

These issues are especially obvious in large projects and collaborative team environments. A good development tool should let developers focus on core coding work instead of getting bogged down in a cumbersome commit workflow.

In recent years, AI has been used more and more widely in software development. From code completion and bug detection to automatic documentation generation, AI is gradually reaching every stage of the development process. In Git workflows, while some tools already support commit message generation, most are limited to single-commit scenarios and lack the ability to intelligently analyze and group changes across the entire working tree.

HagiCode encountered these pain points during development as well. We tried many tools, but each had one limitation or another. Either the functionality was incomplete, or the user experience was not good enough. That is why we ultimately decided to implement AI Compose Commit ourselves.

HagiCode’s AI Compose Commit feature was created to fill that gap. It does not just generate commit messages - it takes over the entire process from file analysis to commit execution.

While implementing AI Compose Commit, we faced several technical challenges:

  1. File semantic understanding: The AI needs to understand semantic relationships between file changes and decide which files belong to the same functional module. This requires deep analysis of file content, directory structure, and change context.

  2. Commit grouping strategy: How should a reasonable grouping standard be defined? By feature, by module, or by file type? Different projects may need different strategies.

  3. Real-time feedback and asynchronous processing: Git operations can take a long time, especially when handling a large number of files. How can we complete complex operations while preserving a good user experience?

  4. Multi-repository support: In a monorepo architecture, operations need to be routed correctly between the main repository and sub-repositories.

  5. Error handling and rollback: If one commit fails, how should already executed commits be handled? Do already staged files need to be rolled back?

  6. Commit message consistency: Generated commit messages need to match the project’s existing style and remain consistent with historical commits.

AI processing over a large number of file changes consumes significant time and compute resources. We needed to optimize in the following areas:

  • Reduce unnecessary AI calls
  • Optimize how file context is constructed
  • Implement efficient Git operation batching

These issues all appeared in real HagiCode usage, and we only arrived at a relatively complete solution through repeated iteration and optimization. If you are building a similar tool, we hope our experience gives you some inspiration.

We adopted a layered architecture to implement AI Compose Commit, ensuring good scalability and maintainability:

GitController provides the POST /api/git/auto-compose-commit endpoint as the entry point. To optimize user experience, we adopted a fire-and-forget asynchronous pattern:

  • After the client sends a request, the server immediately returns HTTP 202 Accepted
  • The actual AI processing runs asynchronously in the background
  • When processing finishes, the client is notified through SignalR

This design ensures that even if AI processing takes several minutes, users still get an immediate response and do not feel that the system is frozen.

2. Application Service Layer (Application Layer)

Section titled “2. Application Service Layer (Application Layer)”

GitAppService is responsible for the core business logic:

  • Repository detection: supports multi-repository management in a monorepo
  • Lock management: prevents conflicts caused by concurrent operations
  • File staging coordination: interacts with the AI processing flow
  • Error rollback: restores state when failures occur

3. Distributed Computing Layer (Orleans Grains)

Section titled “3. Distributed Computing Layer (Orleans Grains)”

AIGrain serves as the core execution unit for AI operations. It implements the AutoComposeCommitAsync method from the IAIGrain interface:

// Define the interface method for AI-powered automatic commit composition
// Parameter notes:
// - projectId: unique project identifier
// - unstagedFiles: list of unstaged files, including file paths and status information
// - projectPath: project root directory path (optional), used to access project context
// Return value: a response object containing execution results, including success/failure status and detailed information
[Alias("AutoComposeCommitAsync")]
[ResponseTimeout("00:20:00")] // 20-minute timeout, suitable for handling large change sets
Task<AutoComposeCommitResponseDto> AutoComposeCommitAsync(
string projectId,
GitFileStatusDto[] unstagedFiles,
string? projectPath = null);

This method sets a 20-minute timeout to handle large change sets. In real-world HagiCode usage, we found that some projects can involve hundreds of changed files in a single pass, requiring more processing time.

Through the abstract IAIService interface, we implemented a pluggable AI service architecture. We currently use the Claude Helper service, but it can be easily switched to other AI providers.

The AI needs to understand the state of each file before it can make intelligent decisions. We build file context through the BuildFileChangesXml method:

/// <summary>
/// Build an XML representation of file changes to provide the AI with complete file context information
/// </summary>
/// <param name="stagedFiles">List of staged files, including file path, status, and old path (for rename operations)</param>
/// <returns>A formatted XML string containing metadata for all files</returns>
private static string BuildFileChangesXml(GitFileStatusDto[] stagedFiles)
{
var sb = new StringBuilder();
sb.AppendLine("<files>");
foreach (var file in stagedFiles)
{
sb.AppendLine(" <file>");
// Use XML escaping to ensure special characters do not break the XML structure
sb.AppendLine($" <path>{System.Security.SecurityElement.Escape(file.Path)}</path>");
sb.AppendLine($" <status>{System.Security.SecurityElement.Escape(file.Status)}</status>");
// Handle file rename scenarios and record the old path so the AI can understand change relationships
if (!string.IsNullOrEmpty(file.OldPath))
{
sb.AppendLine($" <oldPath>{System.Security.SecurityElement.Escape(file.OldPath)}</oldPath>");
}
sb.AppendLine(" </file>");
}
sb.AppendLine("</files>");
return sb.ToString();
}

This XML-based context includes file paths, statuses, and old paths for rename operations, giving the AI complete metadata. With a structured XML format, we ensure that the AI can accurately understand the state and change type of each file.

To let the AI execute Git operations directly, we configured comprehensive tool permissions:

// Define the set of tools the AI can use, including file operations and Git command execution permissions
// Read/Write/Edit: file reading, writing, and editing capabilities
// Bash(git:*): permission to execute all Git commands
// Other Bash commands: used to inspect file contents and directory structure so the AI can understand context
var allowedTools = new[]
{
"Read", "Write", "Edit",
"Bash(git:*)", "Bash(cat:*)", "Bash(ls:*)", "Bash(find:*)",
"Bash(grep:*)", "Bash(head:*)", "Bash(tail:*)", "Bash(wc:*)"
};
// Build the complete AI request object
var request = new AIRequest
{
Prompt = prompt, // Complete prompt template, including task instructions and constraints
WorkingDirectory = projectPath ?? GetTempDirectory(), // Working directory, ensuring the AI runs in the correct project context
AllowedTools = allowedTools, // Allowed tool set
PermissionMode = PermissionMode.bypassPermissions, // Bypass permission checks so Git operations can run directly
LanguagePreference = languagePreference // Language preference setting, ensuring commit messages match user expectations
};

Here we use PermissionMode.bypassPermissions, which allows the AI to execute Git commands directly without user confirmation. This is central to the feature design, but it also requires strict input validation to prevent abuse. In HagiCode’s production deployment, we ensured the safety of this mechanism through backend parameter validation and log monitoring.

After the AI finishes execution, it returns structured results. We implemented a dual parsing strategy to ensure compatibility:

/// <summary>
/// Parse commit execution results returned by the AI, supporting both delimiter format and regex format
/// </summary>
/// <param name="aiResponse">Raw response content returned by the AI</param>
/// <returns>A parsed list of commit results, where each result includes the commit hash and execution status</returns>
private List<CommitResultDto> ParseCommitExecutionResults(string aiResponse)
{
var results = new List<CommitResultDto>();
// Prefer delimiter-based parsing (new format), which is more explicit and reliable
if (aiResponse.Contains("---"))
{
logger.LogDebug("Using delimiter-based parsing for AI response");
results = ParseDelimitedFormat(aiResponse);
if (results.Count > 0)
{
return results; // Successfully parsed, return the results directly
}
logger.LogWarning("Delimiter-based parsing produced no results, falling back to regex");
}
else
{
logger.LogDebug("No delimiter found, using legacy regex-based parsing");
}
// Fall back to regex parsing (old format) to ensure backward compatibility
return ParseLegacyFormat(aiResponse);
}

The delimiter format uses --- to separate commits, making the structure clear and easy to parse:

---
Commit 1: abc123def456
feat(auth): add user login functionality
Implement JWT-based authentication with login form and API endpoints.
Co-Authored-By: Hagicode <noreply@hagicode.com>
---
Commit 2: 789ghi012jkl
docs(readme): update installation instructions
Add new setup steps for Docker environment.
Co-Authored-By: Hagicode <noreply@hagicode.com>
---

This format makes parsing simple and reliable, while also remaining easy for humans to read.

To prevent state conflicts caused by concurrent operations, we implemented a repository lock mechanism:

// Acquire the repository lock to prevent concurrent operations
// Parameter notes:
// - fullPath: full repository path, used to identify different repository instances
// - requestedBy: requester identifier, used for tracking and logging
await _autoComposeLockService.AcquireLockAsync(fullPath, requestedBy);
try
{
// Execute the AI Compose Commit operation
// This section calls an Orleans Grain method to perform the actual AI processing and Git operations
await aiGrain.AutoComposeCommitAsync(projectId, unstagedFiles, projectPath);
}
finally
{
// Ensure the lock is released whether the operation succeeds or fails
// Using a finally block guarantees lock release even when exceptions occur, preventing deadlocks
await _autoComposeLockService.ReleaseLockAsync(fullPath);
}

The lock has a 20-minute timeout, matching the timeout used for AI operations. If the operation fails or times out, the system automatically releases the lock to avoid permanent blocking. In real HagiCode usage, we found this lock mechanism to be extremely important, especially in collaborative environments where multiple developers may trigger AI Compose Commit at the same time.

After processing completes, the system sends a notification to the frontend through SignalR:

/// <summary>
/// Send a notification when automatic commit composition is complete
/// </summary>
/// <param name="projectId">Project identifier, used to route the notification to the correct client</param>
/// <param name="totalCount">Total number of commits, including successes and failures</param>
/// <param name="successCount">Number of successful commits</param>
/// <param name="failureCount">Number of failed commits</param>
/// <param name="success">Whether the overall operation succeeded</param>
/// <param name="error">Error message (if the operation failed)</param>
private async Task SendAutoComposeCommitNotificationAsync(
string projectId,
int totalCount,
int successCount,
int failureCount,
bool success,
string? error)
{
try
{
// Build the notification DTO containing detailed execution results
var notification = new AutoComposeCommitCompletedDto
{
ProjectId = projectId,
TotalCount = totalCount,
SuccessCount = successCount,
FailureCount = failureCount,
Success = success,
Error = error
};
// Broadcast the notification to all connected clients through the SignalR Hub
await messageService.SendAutoComposeCommitCompletedAsync(notification);
logger.LogInformation(
"Auto compose commit notification sent for project {ProjectId}: {SuccessCount}/{TotalCount} succeeded",
projectId, successCount, totalCount);
}
catch (Exception ex)
{
// Log notification errors without affecting the main operation flow
// A notification failure should not cause the entire operation to fail
logger.LogError(ex, "Failed to send auto compose commit notification for project {ProjectId}", projectId);
}
}

After the frontend receives the notification, it can update the UI to show whether the commit succeeded or failed, improving the user experience. This real-time feedback mechanism received strong feedback from HagiCode users, who can clearly see when the operation finishes and what the outcome is.

AI behavior is entirely determined by the prompt, so we carefully designed the prompt template for Auto Compose Commit. Taking the Chinese version as an example (auto-compose-commit.zh-CN.hbs):

At the beginning of the prompt, we explicitly declare support for non-interactive execution mode, which is a critical requirement for CI/CD and automation scripts:

**Important Note**: This prompt may run in a non-interactive environment (such as CI/CD or automation scripts).
**Non-Interactive Mode**:
- Do not use AskUserQuestion or any interactive tools
- When user input is required:
- Use sensible defaults (for example, use feat as the commit type)
- Skip optional confirmation steps
- Record any assumptions made

This design ensures that AI Compose Commit can be used not only in interactive IDE environments, but also integrated into CI/CD pipelines to deliver a fully automated commit workflow.

To prevent the AI from executing dangerous operations, we added strict branch protection rules to the prompt:

**Branch Protection**:
- Do not perform any branch switching operations (git checkout, git switch)
- All `git commit` commands must run on the current branch
- Do not create, delete, or rename branches
- Do not modify untracked files or unstaged changes
- If branch switching is required to complete the operation, return an error instead of executing it

By constraining the AI’s tool usage scope, these rules ensure operational safety. In HagiCode’s practical testing, we verified the effectiveness of these constraints: when the AI encounters a situation that would require a branch switch, it safely returns an error instead of taking dangerous action.

The prompt defines the decision logic for file grouping in detail:

**File Grouping Decision Tree**:
├── Is it a configuration file (package.json, tsconfig.json, .env, etc.)?
│ ├── Yes -> separate commit (type: chore or build)
│ └── No -> continue
├── Is it a documentation file (README.md, *.md, docs/**)?
│ ├── Yes -> separate commit (type: docs)
│ └── No -> continue
├── Is it related to the same feature?
│ ├── Yes -> merge into the same commit
│ └── No -> commit separately
└── Is it a cross-module change?
├── Yes -> group by module
└── No -> group by feature

This decision tree gives the AI clear grouping logic, ensuring the generated commits remain semantically reasonable. In real HagiCode usage, we found that this decision tree can handle the vast majority of common scenarios, and the grouping results match developer expectations.

To keep commit messages consistent with project history, the prompt requires the AI to analyze recent commit history before generation:

**Historical Format Consistency**: Before generating commit messages, you **must** analyze the current repository's commit history to match the existing style.
1. Use `git log -n 15 --pretty=format:"%H|%s|%b%n---%n"` to get the recent commit history
2. Analyze the commits to identify:
- Structural patterns: does the project use multi-paragraph messages? Are there `Changes:` or `Capabilities:` sections?
- Language patterns: are commit messages in English, Chinese, or mixed?
- Common types: which commit types are most often used (`feat`, `fix`, `docs`, etc.)?
- Special formatting: are there `Co-Authored-By` lines? Any other project-specific conventions?
3. Generate commit messages that follow the detected patterns

This analysis ensures that AI-generated commit messages do not feel out of place, but instead remain stylistically aligned with the project’s history. In HagiCode’s multilingual projects, this feature is especially important because it can automatically choose the appropriate language and format based on commit history.

Every commit must include Co-Authored-By information:

**Important**: Every commit must include Co-Authored-By information
- Use the following format: `git commit -m "type(scope): subject" -m "" -m "Co-Authored-By: Hagicode <noreply@hagicode.com>"`
- Or include the `Co-Authored-By` line directly in the commit message

This is not only for contribution compliance, but also for tracing AI-assisted commit history. HagiCode treats this as a mandatory rule to ensure that all AI-generated commits carry a clear source marker.

The full AI Compose Commit workflow is as follows:

  1. User trigger: The user clicks the “AI Auto Compose Commit” button in the Git Status panel or Quick Actions Zone.
  2. API request: The frontend sends a POST request to the /api/git/auto-compose-commit endpoint.
  3. Immediate response: The server returns HTTP 202 Accepted without waiting for processing to finish.
  4. Background processing:
    • GitAppService acquires the repository lock
    • Calls AIGrain.AutoComposeCommitAsync
    • Builds the file context XML
    • Executes the AI prompt so the AI can analyze and perform commits
  5. AI execution:
    • Uses Git commands to obtain all unstaged changes
    • Reads file contents to understand the nature of the changes
    • Groups files by semantic relationship
    • Executes git add and git commit for each group
  6. Result parsing: Parses the execution results returned by the AI.
  7. Notification delivery: Notifies the frontend through SignalR.
  8. Lock release: Releases the repository lock whether the operation succeeds or fails.

This workflow is designed so that users can continue with other work immediately after initiating the operation, without waiting for the AI to finish. Feedback from HagiCode users shows that this asynchronous processing model greatly improves the workflow experience.

We implemented multi-layer error handling:

// Validate request parameters to prevent invalid requests from reaching backend processing logic
if (request.UnstagedFiles == null || request.UnstagedFiles.Count == 0)
{
return BadRequest(new
{
message = "No unstaged files provided. Please make changes in the working directory first.",
status = "validation_failed"
});
}

If an error occurs during AI processing, the system performs a rollback operation and unstages files that were already staged, preventing an inconsistent state from being left behind. In real HagiCode usage, this mechanism saved us from multiple unexpected interruptions and ensured repository state integrity.

The 20-minute timeout ensures that long-running operations do not block resources indefinitely. After a timeout, the system releases the lock and notifies the user that the operation failed. In real HagiCode usage, we found that most operations complete within 2 to 5 minutes, and only extremely large change sets approach the timeout limit.

Best Practices for Using AI Compose Commit

Section titled “Best Practices for Using AI Compose Commit”

AI Compose Commit is best suited for the following scenarios:

  • At the end of a workday, when you need to process changes across many files in one batch
  • After a refactoring operation, when several related files need to be committed separately
  • After a feature is completed, when related changes need to be grouped into commits

It is not suitable for the following scenarios:

  • Quick commits for a single file (a normal commit is faster)
  • Scenarios requiring precise control over commit content
  • Commits containing sensitive information that require human review

Although AI-powered intelligent grouping is powerful, developers should still review the generated commits:

  • Check whether the grouping matches expectations
  • Verify the accuracy of commit messages
  • Confirm that no files were omitted or incorrectly included

If you find an unreasonable grouping, you can use git reset --soft HEAD~N to undo it and regroup. HagiCode’s experience shows that even when AI grouping is smart, manual review is still valuable, especially for important feature commits.

Make sure your project’s Git configuration supports Conventional Commits:

Terminal window
# Install commitlint
npm install -g @commitlint/cli @commitlint/config-conventional
# Configure commitlint
echo "module.exports = {extends: ['@commitlint/config-conventional']}" > commitlint.config.js

This lets you validate commit message format in CI/CD workflows and keeps it aligned with the format generated by AI Compose Commit.

If you want to implement a similar AI-assisted commit feature in your own project, here are our suggestions:

Begin with single commit message generation, then gradually expand to multi-commit grouping. This makes it easier to validate and iterate. HagiCode followed the same path: early versions only supported single commits, and later expanded to intelligent grouping across multiple commits.

Do not implement AI invocation logic from scratch. Using an existing SDK reduces development time and potential bugs. We used the Claude Helper service, which provides a stable interface and robust error handling.

Prompt quality directly determines output quality. Spend time designing a detailed prompt, including:

  • Clear task descriptions
  • Specific output format requirements
  • Rules for handling edge cases
  • Illustrative examples

HagiCode invested heavily in prompt design, and this was one of the key reasons the feature succeeded.

AI operations can fail for many reasons, such as network issues, API rate limits, or content moderation. Make sure your system can handle these errors gracefully and provide meaningful error information.

Do not automate everything completely. Leave users in control. Provide options to review grouping results, adjust groups, and manually edit commit messages to balance automation and flexibility. Although HagiCode supports automatic execution, it still preserves preview and adjustment capabilities.

When constructing file context, filter out files that do not need AI analysis:

// Filter out generated files and excessively large files to reduce the AI processing burden
var relevantFiles = stagedFiles
.Where(f => !IsGeneratedFile(f.Path))
.Where(f => !IsLargeFile(f.Path))
.ToArray();

If multiple independent repositories are supported, commits in different repositories can be processed in parallel to improve overall efficiency.

Cache project commit history analysis results to avoid re-analyzing them every time. Historical format preferences can be stored in configuration files to reduce AI calls.

AI Compose Commit represents a deep application of AI technology in software development tools. By intelligently analyzing file changes, automatically grouping commits, and generating standards-compliant commit messages, it significantly improves the efficiency of Git workflows and allows developers to focus more on core coding work.

During implementation, we learned several important lessons:

  1. User feedback is critical: Early versions used synchronous waiting, and users reported a poor experience. After switching to a fire-and-forget model, satisfaction improved significantly.
  2. Prompt design determines quality: A carefully designed prompt does more to guarantee AI output quality than a complex algorithm.
  3. Safety always comes first: Granting the AI permission to execute Git commands directly improves efficiency, but it must be paired with strict constraints and validation.
  4. Progressive improvement works best: Starting with simple scenarios and gradually increasing complexity is more likely to succeed than trying to implement everything at once.

In the future, we plan to further optimize AI Compose Commit, including:

  • Supporting more commit grouping strategies (by time, by developer, and so on)
  • Integrating code review workflows to trigger review automatically before commits
  • Supporting custom commit message templates to meet the personalized needs of different projects

If you find the approach shared in this article valuable, give HagiCode a try and experience how this feature works in real development. After all, practice is the only criterion for testing truth.


Thank you for reading. If you found this article helpful, please click the like button below so more people can discover it.

This content was created with AI-assisted collaboration, reviewed by me, and reflects my own views and positions.

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.

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.

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?

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.

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.

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.

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.

To address the problems above, we adopted the following systematic solution, optimizing from three dimensions: architectural refactoring, library integration, and enhanced debugging.

Delete JsonRpcEndpoint.cs, AgentSideConnection.cs, and related custom serialization converters such as JsonRpcMessageJsonConverter.

Introduce the StreamJsonRpc NuGet package and use its JsonRpc class to handle the core communication logic.

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 interface
public interface IAcpTransport
{
Task SendAsync(string message, CancellationToken cancellationToken = default);
Task<string> ReceiveAsync(CancellationToken cancellationToken = default);
Task CloseAsync(CancellationToken cancellationToken = default);
}
// WebSocket transport implementation
public class WebSocketTransport : IAcpTransport
{
private readonly WebSocket _webSocket;
public WebSocketTransport(WebSocket webSocket)
{
_webSocket = webSocket;
}
// Implement send and receive methods
// ...
}
// Stdio transport implementation
public 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
// ...
}

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.

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 approach
public class CallbackProxyTarget<T>
{
public Func<T, Task> Callback { get; set; }
}
// Refactored concrete method approach
public 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...
}

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”

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.

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 logging
public 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);
}
}

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.

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.

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
};
  • 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);
}

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.

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:

  1. Replacing dynamic properties with strongly typed DTOs, improving maintainability and reliability
  2. Implementing transport-layer abstraction and protocol-layer separation, improving architectural clarity
  3. Strengthening logging capabilities to make communication problems easier to diagnose
  4. 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.


If this article helped you:


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.

Best Practices for Building a Modern Build System with C# and Nuke

Say Goodbye to Script Hell: Why We Chose C# for a Modern Build System

Section titled “Say Goodbye to Script Hell: Why We Chose C# for a Modern Build System”

A look at how the HagiCode project uses Nuke to build a type-safe, cross-platform, and highly extensible automated build workflow, fully addressing the maintenance pain points of traditional build scripts.

Throughout the long journey of software development, the word “build” tends to inspire both love and frustration. We love it because with a single click, code becomes a product, which is one of the most rewarding moments in programming. We hate it because maintaining that pile of messy build scripts can feel like a nightmare.

In many projects, we are used to writing scripts in Python or using XML configuration files (just imagine the fear of being ruled by <property> tags). But as project complexity grows, especially in projects like HagiCode that involve frontend and backend work, multiple platforms, and multiple languages, traditional build approaches start to show their limits. Scattered script logic, no type checking, weak IDE support… these issues become small traps that repeatedly trip up the development team.

To solve these pain points, in the HagiCode project we decided to introduce Nuke - a modern build system based on C#. It is more than just a tool; it is a new way of thinking about build workflows. Today, let us talk about why we chose it and how it has made our development experience take off.

Hey, let us introduce what we are building

We are developing HagiCode - an AI-powered intelligent coding assistant that makes development smarter, more convenient, and more enjoyable.

Smarter - AI assistance throughout the entire process, from idea to code, multiplying development efficiency. Convenient - Multi-threaded concurrent operations make full use of resources and keep the development workflow smooth. Enjoyable - Gamification mechanisms and an achievement system make coding less tedious and far more rewarding.

The project is evolving quickly. If you are interested in technical writing, knowledge management, or AI-assisted development, feel free to check us out on GitHub~

You might be wondering: “There are so many build systems, like Make, Gradle, or even plain Shell scripts. Why go out of the way to use one built on C#?”

That is actually a great question. Nuke’s core appeal is that it brings the programming language features we know best into the world of build scripts.

1. Modularizing the Build Workflow: The Art of Targets

Section titled “1. Modularizing the Build Workflow: The Art of Targets”

Nuke has a very clear design philosophy: everything is a target.

In traditional scripts, we may end up with hundreds of lines of sequential code and tangled logic. In Nuke, we break the build workflow into independent Targets. Each target is responsible for just one thing, for example:

  • Clean: clean the output directory
  • Restore: restore dependency packages
  • Compile: compile the code
  • Test: run unit tests

This design aligns well with the single responsibility principle. Like building with blocks, we can combine these targets however we want. More importantly, Nuke lets us define dependencies between targets. For example, if you want Test, the system will automatically check whether Compile has already run; if you want Compile, then Restore naturally has to come first.

This dependency graph not only makes the logic clearer, but also greatly improves execution efficiency, because Nuke automatically analyzes the optimal execution path.

2. Type Safety: Saying Goodbye to the Nightmare of Typos

Section titled “2. Type Safety: Saying Goodbye to the Nightmare of Typos”

Anyone who has written build scripts in Python has probably experienced this embarrassment: the script runs for five minutes and then fails because Confi.guration was misspelled, or because a string was passed to a parameter that was supposed to be a number.

The biggest advantage of writing build scripts in C# is type safety. That means:

  • Compile-time checks: while you are typing, the IDE tells you what is wrong instead of waiting until runtime to reveal the issue.
  • Safe refactoring: if you want to rename a variable or method, the IDE can handle it with one refactor action instead of a nervous global search-and-replace.
  • Intelligent completion: powerful IntelliSense completes the code for you, so you do not need to dig through documentation to remember obscure APIs.

3. Cross-Platform: A Unified Build Experience

Section titled “3. Cross-Platform: A Unified Build Experience”

In the past, you might write .bat files on Windows and .sh files on Linux, then add a Python script just to bridge the two. Now, wherever .NET Core (now .NET 5+) can run, Nuke can run too.

This means that whether team members use Windows, Linux, or macOS, and whether they prefer Visual Studio, VS Code, or Rider, everyone executes the same logic. That greatly reduces the environment-specific problems behind the classic “it works on my machine” scenario.

Nuke provides a very elegant parameter parsing mechanism. You do not need to manually parse string[] args. You only need to define a property and add the [Parameter] attribute, and Nuke will automatically map command-line arguments and configuration files for you.

For example, we can easily define the build configuration:

[Parameter("Configuration to build - Default is 'Debug'")]
readonly Configuration BuildConfiguration = IsLocalBuild ? Configuration.Debug : Configuration.Release;
Target Compile => _ => _
.DependsOn(Restore)
.Executes(() =>
{
// Use BuildConfiguration here; it is type-safe
DotNetBuild(s => s
.SetConfiguration(BuildConfiguration)
.SetProjectFile(SolutionFile));
});

This style is both intuitive and less error-prone.

Practical Guide: How to Apply It in a Project

Section titled “Practical Guide: How to Apply It in a Project”

Talking is easy; implementation is what matters. Let us take a look at how we put this approach into practice in the HagiCode project.

We did not want build scripts cluttering the project root, and we also did not want a directory structure so deep that it feels like certain Java projects. So we placed all Nuke-related build files in the nukeBuild/ directory.

The benefits are straightforward:

  • the project root stays clean;
  • the build logic remains cohesive and easy to manage;
  • when new team members join, they can immediately see, “oh, this is where the build logic lives.”

When designing targets, we followed one principle: atomic tasks + dependency flow.

Each target should be small enough to do exactly one thing. For example, Clean should only delete files; do not sneak packaging into it.

A recommended dependency flow looks roughly like this:

Clean -> Restore -> Compile -> Test -> Pack

Of course, this is not absolute. For example, if you only want to run tests and do not want to package, Nuke allows you to run nuke Test directly, and it will automatically take care of the required Restore and Compile steps.

What is the most frustrating thing about build scripts? Unclear error messages. For example, if a build fails and the log only says “Error: 1”, that is enough to drive anyone crazy.

In Nuke, because we can directly use C# exception handling, we can capture and report errors with much greater precision.

Target Publish => _ => _
.DependsOn(Test)
.Executes(() =>
{
try
{
// Try publishing to NuGet
DotNetNuGetPush(s => s
.SetTargetPath(ArtifactPath)
.SetSource("https://api.nuget.org/v3/index.json")
.SetApiKey(ApiKey));
}
catch (Exception ex)
{
Log.Error($"Publishing failed. Team, please check whether the key is correct: {ex.Message}");
throw; // Ensure the build process exits with a non-zero code
}
});

A build script is still code, and code should be tested. Nuke allows us to write tests for the build workflow, ensuring that when we modify the build logic, we do not break the existing release process. This is especially important in continuous integration (CI) pipelines.

By introducing Nuke, HagiCode’s build process has become smoother than ever before. This is not just a tool replacement; it is an upgrade in engineering thinking.

What did we gain?

  • Maintainability: code as configuration, clear logic, and a faster onboarding path for new team members.
  • Stability: strong typing eliminates more than 90% of low-level mistakes.
  • Consistency: a unified cross-platform experience removes environment differences.

If writing build scripts used to feel like “feeling your way through the dark,” then using Nuke feels like “walking at night with the lights on.” If you are tired of maintaining hard-to-debug scripting languages, try bringing your build logic into the world of C# as well. You may discover that build systems can actually be this elegant.


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, reviewed by me, and reflects my own views and position.