Skip to content

AI Integration

2 posts with the tag “AI Integration”

From CLI Calls to SDK Integration: Best Practices for GitHub Copilot in .NET Projects

From CLI Calls to SDK Integration: Best Practices for GitHub Copilot in .NET Projects

Section titled “From CLI Calls to SDK Integration: Best Practices for GitHub Copilot in .NET Projects”

The upgrade path from command-line invocation to official SDK integration has been quite a journey. Today, I want to share the pitfalls we ran into and what we learned while building HagiCode.

After the GitHub Copilot SDK was officially released in 2025, we started integrating it into our AI capability layer. Before that, the project mainly used GitHub Copilot by directly invoking the Copilot CLI command-line tool, but that approach had several obvious issues:

  • Complex process management: We had to manually manage the CLI process lifecycle, startup timeouts, and process cleanup. Processes can crash without warning.
  • Incomplete event handling: Raw CLI invocation makes it hard to capture fine-grained events from model reasoning and tool execution. It is like seeing only the result without the thinking process.
  • Difficult session management: There was no effective mechanism for session reuse and recovery, so every interaction had to start over.
  • Compatibility problems: CLI arguments changed frequently, which meant we constantly had to maintain compatibility logic for those parameters.

These issues became increasingly apparent in day-to-day development, especially when we needed to track model reasoning (thinking) and tool execution status in real time. At that point, it became clear that we needed a lower-level and more complete integration approach.

The approach shared in this article comes from our practical experience in the HagiCode project. HagiCode is an open-source AI coding assistant project, and during development we needed deep integration with GitHub Copilot capabilities, from basic code completion to complex multi-turn conversations and tool calling. Those real-world requirements pushed us to move from CLI invocation to the official SDK.

If this implementation sounds useful to you, there is a good chance our engineering experience can help. In that case, the HagiCode project itself may also be worth checking out. You might even find more project information and links at the end of this article.

The project uses a layered architecture to address the limitations of CLI invocation:

┌─────────────────────────────────────────────────────────┐
│ hagicode-core (Orleans Grains + AI Provider Layer) │
│ - CopilotAIProvider: Converts AIRequest to CopilotOptions │
│ - GitHubCopilotGrain: Orleans distributed execution interface │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ HagiCode.Libs (Shared Provider Layer) │
│ - CopilotProvider: CLI Provider interface implementation │
│ - ICopilotSdkGateway: SDK invocation abstraction │
│ - GitHubCopilotSdkGateway: SDK session management and event dispatch │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ GitHub Copilot SDK (Official .NET SDK) │
│ - CopilotClient: SDK client │
│ - CopilotSession: Session management │
│ - SessionEvent: Event stream │
└─────────────────────────────────────────────────────────┘

This layered design brings several practical technical advantages:

  1. Separation of concerns: Core business logic is decoupled from SDK implementation details.
  2. Testability: The ICopilotSdkGateway interface makes unit testing straightforward.
  3. Reusability: HagiCode.Libs can be referenced by multiple projects.
  4. Maintainability: SDK upgrades only require changes in the Gateway layer, while upper layers remain untouched.

Authentication is the first and most important step of SDK integration. If authentication fails, nothing else matters. We designed a flexible authentication configuration that supports multiple authentication sources:

// CopilotProvider.cs - Authentication source configuration
public class CopilotOptions
{
public bool UseLoggedInUser { get; set; } = true;
public string? GitHubToken { get; set; }
public string? CliUrl { get; set; }
}
// Convert to SDK request
return new CopilotSdkRequest(
GitHubToken: options.AuthSource == CopilotAuthSource.GitHubToken
? options.GitHubToken
: null,
UseLoggedInUser: options.AuthSource != CopilotAuthSource.GitHubToken
);

The benefits of this design are fairly clear:

  • It supports logged-in user mode without requiring a token, which fits desktop scenarios well.
  • It supports GitHub Token mode, which is suitable for server-side deployments and centralized management.
  • It supports overriding the Copilot CLI URL, which helps with enterprise proxy configuration.

In practice, this flexible authentication model greatly simplified configuration across different deployment scenarios. The desktop client can use each user’s own Copilot login state, while the server side can manage authentication centrally through tokens.

One of the most powerful capabilities of the SDK is complete event stream capture. We implemented an event dispatch system that can handle all kinds of SDK events in real time:

// GitHubCopilotSdkGateway.cs - Core logic for event dispatch
internal static SessionEventDispatchResult DispatchSessionEvent(
SessionEvent evt, bool sawDelta)
{
switch (evt)
{
case AssistantReasoningEvent reasoningEvent:
// Capture the model reasoning process
events.Add(new CopilotSdkStreamEvent(
CopilotSdkStreamEventType.ReasoningDelta,
Content: reasoningEvent.Data.Content));
break;
case ToolExecutionStartEvent toolStartEvent:
// Capture the start of a tool invocation
events.Add(new CopilotSdkStreamEvent(
CopilotSdkStreamEventType.ToolExecutionStart,
ToolName: toolStartEvent.Data.ToolName,
ToolCallId: toolStartEvent.Data.ToolCallId));
break;
case ToolExecutionCompleteEvent toolCompleteEvent:
// Capture tool completion and its result
events.Add(new CopilotSdkStreamEvent(
CopilotSdkStreamEventType.ToolExecutionEnd,
Content: ExtractToolExecutionContent(toolCompleteEvent)));
break;
default:
// Preserve unhandled events as RawEvent
events.Add(new CopilotSdkStreamEvent(
CopilotSdkStreamEventType.RawEvent,
RawEventType: evt.GetType().Name));
break;
}
}

The value of this implementation is significant:

  • Complete capture of the model reasoning process (thinking): users can see how the AI is reasoning, not just the final answer.
  • Real-time tracking of tool execution status: we know which tools are running, when they finish, and what they return.
  • Zero event loss: by falling back to RawEvent, we ensure every event is recorded.

In HagiCode, these fine-grained events help users understand how the AI works internally, especially when debugging complex tasks.

After migrating from CLI invocation to the SDK, we found that some existing CLI parameters no longer applied in the SDK. To preserve backward compatibility, we implemented a parameter filtering system:

// CopilotCliCompatibility.cs - Argument filtering
private static readonly Dictionary<string, string> RejectedFlags = new()
{
["--headless"] = "Unsupported startup argument",
["--model"] = "Passed through an SDK-native field",
["--prompt"] = "Passed through an SDK-native field",
["--interactive"] = "Interaction is managed by the provider",
};
public static CopilotCliArgumentBuildResult BuildCliArgs(CopilotOptions options)
{
// Filter out unsupported arguments and keep compatible ones
// Generate diagnostic information
}

This gives us several benefits:

  • It automatically filters incompatible CLI arguments to prevent runtime errors.
  • It generates clear diagnostic messages to help developers locate problems quickly.
  • It keeps the SDK stable and insulated from changes in CLI parameters.

During the upgrade, this compatibility mechanism helped us transition smoothly. Existing configuration files could still be used and only needed gradual adjustments based on the diagnostic output.

Creating Copilot SDK sessions is relatively expensive, and creating and destroying sessions too frequently hurts performance. To solve that, we implemented a session pool management system:

// CopilotProvider.cs - Session pool management
await using var lease = await _poolCoordinator.AcquireCopilotRuntimeAsync(
request,
async ct => await _gateway.CreateRuntimeAsync(sdkRequest, ct),
cancellationToken);
if (lease.IsWarmLease)
{
// Reuse an existing session
yield return CreateSessionReusedMessage();
}
await foreach (var eventData in lease.Entry.Resource.SendPromptAsync(...))
{
yield return MapEvent(eventData);
}

The benefits of session pooling include:

  • Session reuse: requests with the same sessionId can reuse existing sessions and reduce startup overhead.
  • Session recovery support: after a network interruption, previous session state can be restored.
  • Automatic pooling management: expired sessions are cleaned up automatically to avoid resource leaks.

In HagiCode, session pooling noticeably improved responsiveness, especially for continuous conversations.

HagiCode uses Orleans as its distributed framework, and we integrated the Copilot SDK into Orleans Grains:

// GitHubCopilotGrain.cs - Distributed execution
public async IAsyncEnumerable<GitHubCopilotResponse> ExecuteCommandStreamAsync(
string command,
CancellationToken token = default)
{
var provider = await aiProviderFactory.GetProviderAsync(AIProviderType.GitHubCopilot);
await foreach (var chunk in provider.SendMessageAsync(request, null, token))
{
// Map to the unified response format
yield return BuildChunkResponse(chunk, startedAt);
}
}

The advantages of Orleans integration are substantial:

  • Unified AI Provider abstraction: it becomes easy to switch between different AI providers.
  • Multi-tenant isolation: Copilot sessions for different users remain isolated from one another.
  • Persistent session state: session state can be restored even after server restarts.

For scenarios that need to handle a large number of concurrent requests, Orleans provides strong scalability.

Here is a complete configuration example:

{
"AI": {
"Providers": {
"Providers": {
"GitHubCopilot": {
"Enabled": true,
"ExecutablePath": "copilot",
"Model": "gpt-5",
"WorkingDirectory": "/path/to/project",
"Timeout": 7200,
"StartupTimeout": 30,
"UseLoggedInUser": true,
"NoAskUser": true,
"Permissions": {
"AllowAllTools": false,
"AllowedTools": ["Read", "Bash", "Grep"],
"DeniedTools": ["Edit"]
}
}
}
}
}
}

In real-world usage, we summarized several points worth paying attention to:

Startup timeout configuration: The first startup of Copilot CLI can take a relatively long time, so we recommend setting StartupTimeout to at least 30 seconds. If this is the first login, it may take even longer.

Permission management: In production environments, avoid using AllowAllTools: true. Use the AllowedTools allowlist to control which tools are available, and use the DeniedTools denylist to block dangerous operations. This effectively prevents the AI from executing risky commands.

Session management: Requests with the same sessionId automatically reuse sessions. Session state is persisted through ProviderSessionId. Cancellation is propagated via CancellationTokenSource.

Diagnostic output: Incompatible CLI arguments generate messages of type diagnostic. Raw SDK events are preserved as event.raw. Error messages include categories such as startup timeout and argument incompatibility to make troubleshooting easier.

Based on our practical experience, here are a few best practices:

1. Use a tool allowlist

var request = new AIRequest
{
Prompt = "Analyze this file",
AllowedTools = new[] { "Read", "Grep", "Bash(git:*)" }
};

Explicitly specifying the allowed tools through an allowlist helps prevent unexpected AI actions. This is especially important for tools with write permissions, such as Edit, which should be handled with extra care.

2. Set reasonable timeouts

options.Timeout = 3600; // 1 hour
options.StartupTimeout = 60; // 1 minute

Set appropriate timeout values based on task complexity. If the value is too short, tasks may be interrupted. If it is too long, resources may be wasted waiting on unresponsive requests.

3. Enable session reuse

options.SessionId = "my-session-123";

Using the same sessionId for related tasks lets you reuse prior session context and improve response speed.

4. Handle streaming responses

await foreach (var chunk in provider.StreamAsync(request))
{
switch (chunk.Type)
{
case StreamingChunkType.ThinkingDelta:
// Handle the reasoning process
break;
case StreamingChunkType.ToolCallDelta:
// Handle tool invocation
break;
case StreamingChunkType.ContentDelta:
// Handle text output
break;
}
}

Streaming responses let you show AI processing progress in real time, which improves the user experience. This is especially valuable for long-running tasks.

5. Error handling and retries

try
{
await foreach (var chunk in provider.StreamAsync(request))
{
// Handle the response
}
}
catch (CopilotSessionException ex)
{
// Handle session exceptions
logger.LogError(ex, "Copilot session failed");
// Decide whether to retry based on the exception type
}

Proper error handling and retry logic improve overall system stability.

Upgrading from CLI invocation to SDK integration delivered substantial value to the HagiCode project:

  • Improved stability: the SDK provides a more stable interface that is not affected by CLI version changes.
  • More complete functionality: it captures the full event stream, including reasoning and tool execution status.
  • Higher development efficiency: the type-safe SDK interface makes development more efficient and reduces runtime errors.
  • Better user experience: real-time event feedback gives users a clearer understanding of what the AI is doing.

This upgrade was not just a replacement of technical implementation. It was also an architectural optimization of the entire AI capability layer. Through layered design and abstraction interfaces, we gained better maintainability and extensibility.

If you are considering integrating GitHub Copilot into your own .NET project, I hope these practical lessons help you avoid unnecessary detours. The official SDK is indeed more stable and complete than CLI invocation, and it is worth the time to understand and adopt it.


If this article helped you:


That brings this article to a close. Technical writing never really ends, because technology keeps evolving and we keep learning. If you have questions or suggestions while using HagiCode, feel free to contact us anytime.

Thank you for reading. If you found this article useful, you are welcome to like, bookmark, and share it. This content was created with AI-assisted collaboration, and the final version was reviewed and approved by the author.

Hagicode.Libs: Engineering Practice for Unified Integration of Multiple AI Coding Assistant CLIs

Hagicode.Libs: Engineering Practice for Unified Integration of Multiple AI Coding Assistant CLIs

Section titled “Hagicode.Libs: Engineering Practice for Unified Integration of Multiple AI Coding Assistant CLIs”

During the development of the HagiCode project, we needed to integrate multiple AI coding assistant CLIs at the same time, including Claude Code, Codex, and CodeBuddy. Each CLI has different interfaces, parameters, and output formats, and the repeated integration code made the project harder and harder to maintain. In this article, we share how we built a unified abstraction layer with HagiCode.Libs to solve this engineering pain point. You could also say it is simply some hard-earned experience gathered from the pitfalls we have already hit.

The market for AI coding assistants is quite lively now. Besides Claude Code, there are also OpenAI’s Codex, Zhipu’s CodeBuddy, and more. As an AI coding assistant project, HagiCode needs to integrate these different CLI tools across multiple subprojects, including desktop, backend, and web.

At first, the problem was manageable. Integrating one CLI was only a few hundred lines of code. But as the number of CLIs we needed to support kept growing, things started to get messy.

Each CLI has its own command-line argument format, different environment variable requirements, and a wide variety of output formats. Some output JSON, some output streaming JSON, and some output plain text. On top of that, there are cross-platform compatibility issues. Executable discovery and process management work very differently between Windows and Unix systems, so code duplication kept increasing. In truth, it was just a bit more Ctrl+C and Ctrl+V, but maintenance quickly became painful.

The most frustrating part was that every time we wanted to add support for a new CLI capability, we had to change the same code in several projects. That approach was clearly not sustainable in the long run. Code has a temper too; duplicate it too many times and it starts causing trouble.

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 needs to maintain multiple subprojects at the same time, including a frontend VSCode extension, backend AI services, and a cross-platform desktop client. In a way, it was exactly this complex, multi-language, multi-platform environment that led to the birth of HagiCode.Libs. You could say we were forced into it, and so be it.

Although these AI coding assistant CLIs each have their own characteristics, from a technical perspective they share several obvious traits:

Similar interaction patterns: they all start a CLI process, send a prompt, receive streaming responses, parse messages, and then either end or continue the session. At the end of the day, the whole flow follows the same basic mold.

Similar configuration needs: they all need API key authentication, working directory setup, model selection, tool permission control, and session management. After all, everyone is making a living from APIs; the differences are mostly a matter of flavor.

The same cross-platform challenges: they all need to solve executable path resolution (claude vs claude.exe vs /usr/local/bin/claude), process startup and environment variable handling, shell command escaping, and argument construction. Cross-platform work is painful no matter how you describe it. Only people who have stepped into the traps really understand the difference between Windows and Unix.

Based on this analysis, we needed a unified abstraction layer that could provide a consistent interface, encapsulate cross-platform CLI discovery logic, handle streaming output parsing, and support both dependency injection and non-DI scenarios. It is the kind of problem that makes your head hurt just thinking about it, but you still have to face it. After all, it is our own project, so we have to finish it even if we have to cry our way through it.

We created HagiCode.Libs, a lightweight .NET 10 library workspace released under the MIT license and now published on GitHub. It may not be some world-shaking masterpiece, but it is genuinely useful for solving real problems.

HagiCode.Libs/
├── src/
│ ├── HagiCode.Libs.Core/ # Core capabilities
│ │ ├── Discovery/ # CLI executable discovery
│ │ ├── Process/ # Cross-platform process management
│ │ ├── Transport/ # Streaming message transport
│ │ └── Environment/ # Runtime environment resolution
│ ├── HagiCode.Libs.Providers/ # Provider implementations
│ │ ├── ClaudeCode/ # Claude Code provider
│ │ ├── Codex/ # Codex provider
│ │ └── Codebuddy/ # CodeBuddy provider
│ ├── HagiCode.Libs.ConsoleTesting/ # Testing framework
│ ├── HagiCode.Libs.ClaudeCode.Console/
│ ├── HagiCode.Libs.Codex.Console/
│ └── HagiCode.Libs.Codebuddy.Console/
└── tests/ # xUnit tests

When designing HagiCode.Libs, we followed a few principles. They all came from lessons learned the hard way:

Zero heavy framework dependencies: it does not depend on ABP or any other large framework, which keeps it lightweight. These days, the fewer dependencies you have, the fewer headaches you get. Most people have already been beaten up by dependency hell at least once.

Cross-platform support: native support for Windows, macOS, and Linux, without writing separate code for different platforms. One codebase that runs everywhere is a pretty good thing.

Streaming processing: CLI output is handled with asynchronous streams, which fits modern .NET programming patterns much better. Times change, and async is king.

Flexible integration: it supports dependency injection scenarios while also allowing direct instantiation. Different people have different preferences, so we wanted it to be convenient either way.

If your project already uses dependency injection, such as ASP.NET Core or the generic host, you can integrate it directly. It is a small thing, but a well-behaved one:

using HagiCode.Libs.Providers;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddHagiCodeLibs();
await using var provider = services.BuildServiceProvider();
var claude = provider.GetRequiredService<ICliProvider<ClaudeCodeOptions>>();
var options = new ClaudeCodeOptions
{
ApiKey = "your-api-key",
Model = "claude-sonnet-4-20250514"
};
await foreach (var message in claude.ExecuteAsync(options, "Hello, Claude!"))
{
Console.WriteLine($"{message.Type}: {message.Content}");
}

If you are writing a simple script or working in a non-DI scenario, creating an instance directly also works. Put simply, it depends on your personal preference:

var claude = new ClaudeCodeProvider();
var options = new ClaudeCodeOptions
{
ApiKey = "sk-ant-xxx",
Model = "claude-sonnet-4-20250514"
};
await foreach (var message in claude.ExecuteAsync(options, "Help me write a quicksort"))
{
// Handle messages
}

Both approaches use the same underlying implementation, so you can choose the integration style that best fits your project. There is no universal right answer in this world. What suits you is the best option. It may sound cliché, but it is true.

Each provider has its own dedicated testing console project, making it easier to validate the integration independently. Testing is one of those things where if you are going to do it, you should do it properly:

Terminal window
# Claude Code tests
dotnet run --project src/HagiCode.Libs.ClaudeCode.Console -- --test-provider
dotnet run --project src/HagiCode.Libs.ClaudeCode.Console -- --test-all claude
# CodeBuddy tests
dotnet run --project src/HagiCode.Libs.Codebuddy.Console -- --test-provider codebuddy-cli
# Codex tests
dotnet run --project src/HagiCode.Libs.Codex.Console -- --test-provider codex-cli

The testing scenarios cover several key cases:

  • Ping: health check to confirm the CLI is available
  • Simple Prompt: basic prompt test
  • Complex Prompt: multi-turn conversation test
  • Session Restore/Resume: session recovery test
  • Repository Analysis: repository analysis test

This standalone testing console design is especially useful during debugging because it lets us quickly identify whether the issue is in the HagiCode.Libs layer or in the CLI itself. Debugging is really just about finding where the problem is. Once the direction is right, you are already halfway there.

Cross-platform compatibility is one of the core goals of HagiCode.Libs. We configured the GitHub Actions workflow .github/workflows/cli-discovery-cross-platform.yml to run real CLI discovery validation across ubuntu-latest, macos-latest, and windows-latest.

This ensures that every code change does not break cross-platform compatibility. During local development, you can also reproduce it with the following commands. After all, you cannot ask CI to take the blame for everything. Your local environment should be able to run it too:

Terminal window
npm install --global @anthropic-ai/claude-code@2.1.79
HAGICODE_REAL_CLI_TESTS=1 dotnet test --filter "Category=RealCli"

HagiCode.Libs uses asynchronous streams to process CLI output. Compared with traditional callback or event-based approaches, this fits the asynchronous programming style of modern .NET much better. In the end, this is simply how technology moves forward, whether anyone likes it or not:

public async IAsyncEnumerable<CliMessage> ExecuteAsync(
TOptions options,
string prompt,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Start the CLI process
// Parse streaming JSON output
// Yield the CliMessage sequence
}

The message types include:

  • user: user message
  • assistant: assistant response
  • tool_use: tool invocation
  • result: session end

This design lets callers handle streaming output flexibly, whether for real-time display, buffered post-processing, or forwarding to other services. Why worry whether the sky is sunny or cloudy? What matters is that once the idea opens up, you can use it however you like.

The HagiCode.Libs.Exploration module provides Git repository discovery and status checking, which is especially useful in repository analysis scenarios. This feature was also born out of necessity, because HagiCode needs to analyze repositories:

// Discover Git repositories
var repositories = await GitRepositoryDiscovery.DiscoverAsync("/path/to/search");
// Get repository information
var info = await GitRepository.GetInfoAsync(repoPath);
Console.WriteLine($"Branch: {info.Branch}, Remote: {info.RemoteUrl}");
Console.WriteLine($"Has uncommitted changes: {info.HasUncommittedChanges}");

HagiCode’s code analysis capabilities use this module to identify project structure and Git status. It is a good example of making full use of what we built.

Based on our practice in the HagiCode project, there are several points that deserve special attention. They are all real issues that need to be handled carefully:

API key security: do not hardcode API keys in your code. Use environment variables or configuration management instead. HagiCode.Libs supports passing configuration through Options objects, making it easier to integrate with different configuration sources. When it comes to security, there is no such thing as being too careful.

CLI version pinning: in CI/CD, we pin specific versions, such as @anthropic-ai/claude-code@2.1.79, to reduce uncertainty caused by version drift. It is also a good idea to use fixed versions in local development. Versioning can be painful. If you do not pin versions, the problem will teach you a lesson very quickly.

Test categorization: default tests use fake providers to keep them deterministic and fast, while real CLI tests must be enabled explicitly. This gives CI fast feedback while still allowing real-environment validation when needed. Striking that balance is never easy. Speed and stability always require trade-offs.

Session management: different CLIs have different session recovery mechanisms. Claude Code uses the .claude/ directory to store sessions, while Codex and CodeBuddy each have their own approaches. When using them, be sure to check their respective documentation and understand the details of their session persistence mechanisms. There is no harm in understanding it clearly.

HagiCode.Libs is the unified abstraction layer we built during the development of HagiCode to solve the repeated engineering work involved in multi-CLI integration. By providing a consistent interface, encapsulating cross-platform details, and supporting flexible integration patterns, it greatly reduces the engineering complexity of integrating multiple AI coding assistants. Much may fade away, but the experience remains.

If you also need to integrate multiple AI CLI tools in your project, or if you are interested in cross-platform process management and streaming message handling, feel free to check it out on GitHub. The project is released under the MIT license, and contributions and feedback are welcome. In the end, it is a happy coincidence that we met here, so since you are already here, we might as well become friends.

The approach shared in this article was shaped by real pitfalls and real optimization work inside HagiCode. What else could we do? Running into pitfalls is normal. If you think this solution is valuable, then perhaps our engineering work is doing all right. And HagiCode itself may also be worth your attention. You might even find a pleasant surprise.


If this article helped you:

Thank you for reading. If you found this article useful, you are welcome to like, bookmark, and share it. This content was created with AI-assisted collaboration, and the final content was reviewed and confirmed by the author.