Skip to content

Blog

Guide to Implementing Hotword Support for Doubao Speech Recognition

Guide to Implementing Hotword Support for Doubao Speech Recognition

Section titled “Guide to Implementing Hotword Support for Doubao Speech Recognition”

This article explains in detail how to implement hotword support for Doubao speech recognition in the HagiCode project. By using both custom hotwords and platform hotword tables, you can significantly improve recognition accuracy for domain-specific vocabulary.

Speech recognition technology has developed for many years, yet one problem has consistently bothered developers. General-purpose speech recognition models can cover everyday language, but they often fall short when it comes to professional terminology, product names, and personal names. Think about it: a voice assistant in the medical field needs to accurately recognize terms like “hypertension,” “diabetes,” and “coronary heart disease”; a legal system needs to precisely capture terms such as “cause of action,” “defense,” and “burden of proof.” In these scenarios, a general-purpose model is trying its best, but that is often not enough.

We ran into the same challenge in the HagiCode project. As a multifunctional AI coding assistant, HagiCode needs to handle speech recognition for a wide range of technical terminology. However, the Doubao speech recognition API, in its default configuration, could not fully meet our accuracy requirements for specialized terms. It is not that Doubao is not good enough; rather, every domain has its own terminology system. After some research and technical exploration, we found that the Doubao speech recognition API actually provides hotword support. With a straightforward configuration, it can significantly improve the recognition accuracy of specific vocabulary. In a sense, once you tell it which words to pay attention to, it listens for them more carefully.

What this article shares is the complete solution we used in the HagiCode project to implement Doubao speech recognition hotwords. Both modes, custom hotwords and platform hotword tables, are available, and they can also be combined. With this solution, developers can flexibly configure hotwords based on business scenarios so the speech recognition system can better “recognize” professional, uncommon, yet critical vocabulary.

The solution shared in this article comes from our practical experience in the HagiCode project. HagiCode is an open-source AI coding assistant project with a modern technology stack, designed to provide developers with an intelligent programming assistance experience. As a complex multilingual, multi-platform project, HagiCode needs to handle speech recognition scenarios involving many technical terms, which in turn drove our research into and implementation of the hotword feature.

If you are interested in HagiCode’s technical implementation, you can visit the GitHub repository for more details, or check out our official documentation for the complete installation and usage guide.

The Doubao speech recognition API provides two ways to configure hotwords, and each one has its own ideal use cases and advantages.

Custom hotword mode lets us pass hotword text directly through the corpus.context field. This approach is especially suitable for scenarios where you need to quickly configure a small number of hotwords, such as temporarily recognizing a product name or a person’s name. In HagiCode’s implementation, we parse the multi-line hotword text entered by the user into a list of strings, then format it into the context_data array required by the Doubao API. This approach is very direct: you simply tell the system which words to pay attention to, and it does exactly that.

Platform hotword table mode uses the corpus.boosting_table_id field to reference a preconfigured hotword table in the Doubao self-learning platform. This approach is suitable for scenarios where you need to manage a large number of hotwords. We can create and maintain hotword tables on the Doubao self-learning platform, then reference them by ID. For a project like HagiCode, where specialized terms need to be continuously updated and maintained, this mode offers much better manageability. Once the number of hotwords grows, having a centralized place to manage them is far better than entering them manually every time.

Interestingly, these two modes can also be used together. The Doubao API supports including both custom hotwords and a platform hotword table ID in the same request, with the combination strategy controlled by the combine_mode parameter. This flexibility allows HagiCode to handle a wide range of complex professional terminology recognition needs. Sometimes, combining multiple approaches produces better results.

In HagiCode’s frontend implementation, we defined a complete set of hotword configuration types and validation logic. The first part is the type definition:

export interface HotwordConfig {
contextText: string; // Multi-line hotword text
boostingTableId: string; // Doubao platform hotword table ID
combineMode: boolean; // Whether to use both together
}

This simple interface contains all configuration items for the hotword feature. Among them, contextText is the part users interact with most directly: we allow users to enter one hotword phrase per line, which is very intuitive. Asking users to enter one term per line is much easier than making them understand a complicated configuration format.

Next comes the validation function. Based on the Doubao API limitations, we defined strict validation rules: at most 100 lines of hotword text, up to 50 characters per line, and no more than 5000 characters in total; boosting_table_id can be at most 200 characters and may contain only letters, numbers, underscores, and hyphens. These limits are not arbitrary; they come directly from the official Doubao documentation. API limits are API limits, and we have to follow them.

export function validateContextText(contextText: string): HotwordValidationResult {
if (!contextText || contextText.trim().length === 0) {
return { isValid: true, errors: [] };
}
const lines = contextText.split('\n').filter(line => line.trim().length > 0);
const errors: string[] = [];
if (lines.length > 100) {
errors.push(`Hotword line count cannot exceed 100 lines; current count is ${lines.length}`);
}
const totalChars = contextText.length;
if (totalChars > 5000) {
errors.push(`Total hotword character count cannot exceed 5000; current count is ${totalChars}`);
}
for (let i = 0; i < lines.length; i++) {
if (lines[i].length > 50) {
errors.push(`Hotword on line ${i + 1} exceeds the 50-character limit`);
}
}
return { isValid: errors.length === 0, errors };
}
export function validateBoostingTableId(boostingTableId: string): HotwordValidationResult {
if (!boostingTableId || boostingTableId.trim().length === 0) {
return { isValid: true, errors: [] };
}
const errors: string[] = [];
if (boostingTableId.length > 200) {
errors.push(`boosting_table_id cannot exceed 200 characters; current count is ${boostingTableId.length}`);
}
if (!/^[a-zA-Z0-9_-]+$/.test(boostingTableId)) {
errors.push('boosting_table_id can contain only letters, numbers, underscores, and hyphens');
}
return { isValid: errors.length === 0, errors };
}

These validation functions run immediately when the user configures hotwords, ensuring that problems are caught as early as possible. From a user experience perspective, this kind of instant feedback is very important. It is always better for users to know what is wrong while they are typing rather than after they submit.

In HagiCode’s frontend implementation, we chose to use the browser’s localStorage to store hotword configuration. There were several considerations behind this design decision. First, hotword configuration is highly personalized, and different users may have different domain-specific needs. Second, this approach simplifies the backend implementation because it does not require extra database tables or API endpoints. Finally, after users configure it once in the browser, the settings can be loaded automatically on subsequent uses, which is very convenient. Put simply, it is the easiest approach.

const HOTWORD_STORAGE_KEYS = {
contextText: 'hotword-context-text',
boostingTableId: 'hotword-boosting-table-id',
combineMode: 'hotword-combine-mode',
} as const;
export const DEFAULT_HOTWORD_CONFIG: HotwordConfig = {
contextText: '',
boostingTableId: '',
combineMode: false,
};
// Load hotword configuration
export function loadHotwordConfig(): HotwordConfig {
const contextText = localStorage.getItem(HOTWORD_STORAGE_KEYS.contextText) || '';
const boostingTableId = localStorage.getItem(HOTWORD_STORAGE_KEYS.boostingTableId) || '';
const combineMode = localStorage.getItem(HOTWORD_STORAGE_KEYS.combineMode) === 'true';
return { contextText, boostingTableId, combineMode };
}
// Save hotword configuration
export function saveHotwordConfig(config: HotwordConfig): void {
localStorage.setItem(HOTWORD_STORAGE_KEYS.contextText, config.contextText);
localStorage.setItem(HOTWORD_STORAGE_KEYS.boostingTableId, config.boostingTableId);
localStorage.setItem(HOTWORD_STORAGE_KEYS.combineMode, String(config.combineMode));
}

The logic in this code is straightforward and clear. We read from localStorage when loading configuration, and write to localStorage when saving it. We also provide a default configuration so the system can still work properly when no configuration exists yet. There has to be a sensible default, after all.

In HagiCode’s backend implementation, we needed to add hotword-related properties to the SDK configuration class. Taking C# language characteristics and usage patterns into account, we used List<string> to store custom hotword contexts:

public class DoubaoVoiceConfig
{
/// <summary>
/// App ID
/// </summary>
public string AppId { get; set; } = string.Empty;
/// <summary>
/// Access token
/// </summary>
public string AccessToken { get; set; } = string.Empty;
/// <summary>
/// Service URL
/// </summary>
public string ServiceUrl { get; set; } = string.Empty;
/// <summary>
/// Custom hotword context list
/// </summary>
public List<string>? HotwordContexts { get; set; }
/// <summary>
/// Doubao platform hotword table ID
/// </summary>
public string? BoostingTableId { get; set; }
}

The design of this configuration class follows HagiCode’s usual concise style. HotwordContexts is a nullable list type, and BoostingTableId is a nullable string, so when there is no hotword configuration, these properties have no effect on the request at all. If you are not using the feature, it should stay out of the way.

Payload construction is the core of the entire hotword feature. Once we have hotword configuration, we need to format it into the JSON structure required by the Doubao API. This process happens before the SDK sends the request:

private void AddCorpusToRequest(Dictionary<string, object> request)
{
var corpus = new Dictionary<string, object>();
// Add custom hotwords
if (Config.HotwordContexts != null && Config.HotwordContexts.Count > 0)
{
corpus["context"] = new Dictionary<string, object>
{
["context_type"] = "dialog_ctx",
["context_data"] = Config.HotwordContexts
.Select(text => new Dictionary<string, object> { ["text"] = text })
.ToList()
};
}
// Add platform hotword table ID
if (!string.IsNullOrEmpty(Config.BoostingTableId))
{
corpus["boosting_table_id"] = Config.BoostingTableId;
}
// Add corpus to the request only when it is not empty
if (corpus.Count > 0)
{
request["corpus"] = corpus;
}
}

This code shows how to dynamically construct the corpus field based on configuration. The key point is that we add the corpus field only when hotword configuration actually exists. This design ensures backward compatibility: when no hotwords are configured, the request structure remains exactly the same as before. Backward compatibility matters; adding a feature should not disrupt existing logic.

Between the frontend and backend, hotword parameters are passed through WebSocket control messages. HagiCode is designed so that when the frontend starts recording, it loads the hotword configuration from localStorage and sends it to the backend through a WebSocket message.

const controlMessage = {
type: 'control',
payload: {
command: 'StartRecognition',
contextText: '高血压\n糖尿病\n冠心病',
boosting_table_id: 'medical_table',
combineMode: false
}
};

There is one detail to note here: the frontend passes multi-line text separated by newline characters, and the backend needs to parse it. The backend WebSocket handler parses these parameters and passes them to the SDK:

private async Task HandleControlMessageAsync(
string connectionId,
DoubaoSession session,
ControlMessage message)
{
if (message.Payload is SessionControlRequest controlRequest)
{
// Parse hotword parameters
string? contextText = controlRequest.ContextText;
string? boostingTableId = controlRequest.BoostingTableId;
bool? combineMode = controlRequest.CombineMode;
// Parse multi-line text into a hotword list
if (!string.IsNullOrEmpty(contextText))
{
var hotwords = contextText
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(s => s.Trim())
.Where(s => s.Length > 0)
.ToList();
session.HotwordContexts = hotwords;
}
session.BoostingTableId = boostingTableId;
}
}

With this design, passing hotword configuration from frontend to backend becomes clear and efficient. There is nothing especially mysterious about it; the data is simply passed through layer by layer.

In real usage, configuring custom hotwords is very simple. Open the speech recognition settings page in HagiCode and find the “Hotword Configuration” section. In the “Custom Hotword Text” input box, enter one hotword phrase per line.

For example, if you are developing a medical-related application, you could configure it like this:

高血压
糖尿病
冠心病
心绞痛
心肌梗死
心力衰竭

After you save the configuration, these hotwords are automatically passed to the Doubao API every time speech recognition starts. In our tests, once hotwords were configured, the recognition accuracy for related professional terms improved noticeably. The improvement is real, and clearly better than before.

If you need to manage a large number of hotwords, or if the hotwords need frequent updates, the platform hotword table mode is a better fit. First, create a hotword table on the Doubao self-learning platform and obtain the generated boosting_table_id, then enter this ID on the HagiCode settings page.

The Doubao self-learning platform provides capabilities such as bulk import and categorized management for hotwords, which is very practical for teams that need to manage large sets of specialized terminology. By managing hotwords on the platform, you can maintain them centrally and roll out updates consistently. Once the hotword list becomes large, having a single place to manage it is much more practical than manual entry every time.

In some complex scenarios, you may need to use both custom hotwords and a platform hotword table at the same time. In that case, simply configure both in HagiCode and enable the “Combination Mode” switch.

In combination mode, the Doubao API considers both hotword sources at the same time, so recognition accuracy is usually higher than using either source alone. However, it is worth noting that combination mode increases request complexity, so it is best to decide whether to enable it after practical testing. More complexity is only worth it if the real-world results justify it.

Integrating the hotword feature into the HagiCode project is very straightforward. Here are some commonly used code snippets:

import {
loadHotwordConfig,
saveHotwordConfig,
validateHotwordConfig,
parseContextText,
getEffectiveHotwordMode,
type HotwordConfig
} from '@/types/hotword';
// Load and validate configuration
const config = loadHotwordConfig();
const validation = validateHotwordConfig(config);
if (!validation.isValid) {
console.error('Hotword configuration validation failed:', validation.errors);
return;
}
// Parse hotword text
const hotwords = parseContextText(config.contextText);
console.log('Parsed hotwords:', hotwords);
// Get effective hotword mode
const mode = getEffectiveHotwordMode(config);
console.log('Current hotword mode:', mode);

Backend usage is similarly concise:

var config = new DoubaoVoiceConfig
{
AppId = "your_app_id",
AccessToken = "your_access_token",
ServiceUrl = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async",
// Configure custom hotwords
HotwordContexts = new List<string>
{
"高血压",
"糖尿病",
"冠心病"
},
// Configure platform hotword table
BoostingTableId = "medical_table_v1"
};
var client = new DoubaoVoiceClient(config, logger);
await client.ConnectAsync();
await client.SendFullClientRequest();

There are several points that deserve special attention when implementing and using the hotword feature.

First is the character limit. The Doubao API has strict restrictions on hotwords, including line count, characters per line, and total character count. If any limit is exceeded, the API returns an error. In HagiCode’s frontend implementation, we check these constraints during user input through validation functions, which prevents invalid configurations from being sent to the backend. Catching problems early is always better than waiting for the API to fail.

Second is the format of boosting_table_id. This field allows only letters, numbers, underscores, and hyphens, and it cannot contain spaces or other special characters. When creating a hotword table on the Doubao self-learning platform, be sure to follow the naming rules. That kind of strict format validation is common for APIs.

Third is backward compatibility. Hotword parameters are entirely optional. If no hotwords are configured, the system behaves exactly as it did before. This design ensures that existing users are not affected in any way, and it also makes gradual migration and upgrades easier. Adding a feature should not disrupt the previous logic.

Finally, there is error handling. When hotword configuration is invalid, the Doubao API returns corresponding error messages. HagiCode’s implementation records detailed logs to help developers troubleshoot issues. At the same time, the frontend displays validation errors in the UI to help users correct the configuration. Good error handling naturally leads to a better user experience.

Through this article, we have provided a detailed introduction to the complete solution for implementing Doubao speech recognition hotwords in the HagiCode project. This solution covers the entire process from requirement analysis and technical selection to code implementation, giving developers a practical example they can use for reference.

The key points can be summarized as follows. First, the Doubao API supports both custom hotwords and platform hotword tables, and they can be used independently or in combination. Second, the frontend uses localStorage to store configuration in a simple and efficient way. Third, the backend passes hotword parameters by dynamically constructing the corpus field, preserving strong backward compatibility. Fourth, comprehensive validation logic ensures configuration correctness and avoids invalid requests. Overall, the solution is not complicated; it simply follows the API requirements carefully.

Implementing the hotword feature further strengthens HagiCode’s capabilities in the speech recognition domain. By flexibly configuring business-related professional terms, developers can help the speech recognition system better understand content from specific domains and therefore provide more accurate services. Ultimately, technology should serve real business needs, and solving practical problems is what matters most.

If you found this article helpful, feel free to give HagiCode a Star on GitHub. Your support motivates us to keep sharing technical practice and experience. In the end, writing and sharing technical content that helps others is a pleasure in itself.


Thank you for reading. If you found this article useful, 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.

Solving Browser WebSocket Authentication Challenges: A Practical Proxy Pattern for Doubao Speech Recognition

Solving Browser WebSocket Authentication Challenges: A Practical Proxy Pattern for Doubao Speech Recognition

Section titled “Solving Browser WebSocket Authentication Challenges: A Practical Proxy Pattern for Doubao Speech Recognition”

The browser WebSocket API does not support custom HTTP headers, which creates a challenge for speech recognition services that require authentication data in headers. This article shares how we solved that problem in the HagiCode project with a backend proxy pattern, and how the approach evolved from playground experiments to production use.

When we started building speech recognition for the HagiCode project, we confidently chose ByteDance’s Doubao speech recognition service. The initial design was straightforward: let the frontend connect directly to Doubao’s WebSocket service. How hard could that be? Just open a connection and send some data, right?

Then came the surprise: Doubao’s API requires authentication information to be passed through HTTP headers, including things like accessToken and secretKey. That immediately became awkward, because the browser WebSocket API simply does not support setting custom headers.

So what do you do when the browser will not let you send them?

At the time, we weighed two options:

  1. Put the credentials into URL query parameters - simple and blunt
  2. Add a proxy layer on the backend - more work at first glance

The first option exposes credentials directly in frontend code and local storage. Is that really safe? I was not comfortable with it. On top of that, some APIs require header-based verification, so this approach is not even viable.

In the end, we chose the second option: implement a WebSocket proxy on the backend. Coincidentally, this pattern was first validated in our playground environment, and only after we confirmed its stability did we move it into production. After all, nobody wants production to double as a lab experiment.

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

HagiCode is an AI coding assistant project with voice interaction support. Because we needed to call a speech recognition service from the frontend, we ran straight into this WebSocket authentication problem, which led us to the solution described here. Sometimes these technical roadblocks are frustrating, but they also force you to learn patterns that turn out to be useful later.

The standard WebSocket API looks wonderfully simple:

const ws = new WebSocket('wss://example.com/ws');

But that simplicity is exactly where the problem lies - it only passes parameters in the URL, and it cannot set headers the way an HTTP request can:

// This is not supported in the WebSocket API
const ws = new WebSocket('wss://example.com/ws', {
headers: {
'Authorization': 'Bearer token'
}
});

And that is the core issue. For services like Doubao speech recognition that depend on header-based authentication, this limitation is a hard blocker.

Once you accept that constraint, the architecture has to change.

When designing the solution, we compared the trade-offs carefully.

Decision 1: Choosing the proxy pattern

We compared two approaches:

OptionProsConsDecision
Native WebSocketLightweight, simple, direct forwardingConnection management must be handled manuallyChosen
SignalRAutomatic reconnection, strong typingOverly complex, extra dependenciesRejected

We ultimately chose native WebSocket. To be honest, it was the lightest option and a better fit for simple bidirectional binary stream forwarding. Pulling in SignalR would have felt like overengineering for this use case, and it could add extra latency.

Decision 2: Connection management strategy

We adopted a “one connection, one session” model - each frontend WebSocket connection maps to its own independent Doubao backend connection.

The benefits are straightforward:

  • Simple to implement and aligned with the common usage pattern
  • Easier to debug and troubleshoot
  • Good resource isolation, preventing interference between sessions

Put simply, the direct solution is sometimes the best one. Complexity does not automatically make a design better.

Decision 3: Storing authentication data

Credentials are stored in backend configuration files (appsettings.yml or environment variables) and loaded through dependency injection:

  • Simple configuration model that matches existing backend conventions
  • Sensitive data never reaches the frontend
  • Supports multi-environment setup for development, testing, and production

That level of separation matters. No one wants credentials floating around in places they should not be.

The overall data flow looks like this:

Frontend (browser)
│ ws://backend/api/voice/ws
│ WebSocket (binary)
Backend (proxy)
│ wss://openspeech.bytedance.com/
│ (with authentication headers)
Doubao API

The flow itself is not complicated:

  1. The frontend connects to the backend proxy through WebSocket
  2. The backend proxy receives audio data and connects to the Doubao API with authenticated headers
  3. The Doubao API returns recognition results, and the proxy forwards them to the frontend
  4. The whole process remains fully asynchronous with bidirectional streaming

Once the responsibilities are split clearly, the design becomes quite natural.

app.Map("/ws", async context =>
{
if (context.WebSockets.IsWebSocketRequest)
{
// Read configuration from query parameters
var appId = context.Request.Query["appId"];
var accessToken = context.Request.Query["accessToken"];
// Validate required parameters
if (string.IsNullOrEmpty(appId) || string.IsNullOrEmpty(accessToken))
{
context.Response.StatusCode = 400;
return;
}
// Accept the WebSocket connection
using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
// Message handling loop
var buffer = new byte[4096];
while (!webSocket.CloseStatus.HasValue)
{
var result = await webSocket.ReceiveAsync(buffer, CancellationToken.None);
if (result.MessageType == WebSocketMessageType.Close)
{
await webSocket.CloseAsync(
result.CloseStatus.Value,
result.CloseStatusDescription,
CancellationToken.None);
break;
}
// Process audio data
await HandleAudioDataAsync(buffer, result.Count);
}
}
});
public class DoubaoSessionManager : IDoubaoSessionManager
{
private readonly ConcurrentDictionary<string, DoubaoSession> _sessions = new();
public DoubaoSession CreateSession(string connectionId)
{
var session = new DoubaoSession(connectionId);
_sessions[connectionId] = session;
return session;
}
public async Task SendAudioAsync(string connectionId, byte[] audioData)
{
if (_sessions.TryGetValue(connectionId, out var session))
{
await session.SendAudioAsync(audioData);
}
}
public void RemoveSession(string connectionId)
{
if (_sessions.TryRemove(connectionId, out var session))
{
session.Dispose();
}
}
}

Using ConcurrentDictionary for session management means thread safety is largely handled for us. Each incoming connection gets its own session, and cleanup happens automatically on disconnect.

public class ClientConfigDto
{
public string AppId { get; set; } = null!;
public string Access set; } =Token { get; null!;
public string? ServiceUrl { get; set; }
public string? ResourceId { get; set; }
public int? SampleRate { get; set; }
public int? BitsPerSample { get; set; }
public int? Channels { get; set; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(AppId))
throw new ArgumentException("AppId is required");
if (string.IsNullOrWhiteSpace(AccessToken))
throw new ArgumentException("AccessToken is required");
}
}

Configuration validation helps surface problems during startup instead of letting them fail later at runtime. That safeguard is worth having.

The frontend and backend use JSON text messages for control, while binary messages carry audio data.

Example control message:

{
"type": "control",
"messageId": "msg_123",
"timestamp": "2026-03-03T10:00:00Z",
"payload": {
"command": "StartRecognition",
"parameters": {
"hotwordId": "hotword1",
"boosting_table_id": "table123"
}
}
}

Example recognition result:

{
"type": "result",
"timestamp": "2026-03-03T10:00:03Z",
"payload": {
"text": "Hello world",
"confidence": 0.95,
"duration": 1500,
"isFinal": true,
"utterances": [
{
"text": "Hello",
"startTime": 0,
"endTime": 800,
"definite": true
}
]
}
}

This design separates control signals from audio payloads, which makes the system easier to reason about and implement. Splitting responsibilities cleanly is often the right call.

class DoubaoVoiceClient {
constructor(config) {
this.config = config;
this.ws = null;
}
async connect() {
const url = new URL(this.config.wsUrl);
// Add query parameters
Object.entries(this.config.params).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
this.ws = new WebSocket(url);
return new Promise((resolve, reject) => {
this.ws.onopen = () => {
console.log('[DoubaoVoice] Connected');
resolve();
};
this.ws.onmessage = (event) => {
this._handleMessage(JSON.parse(event.data));
};
this.ws.onerror = reject;
});
}
_handleMessage(message) {
switch (message.type) {
case 'status':
this._handleStatus(message.payload);
break;
case 'result':
this.onResult?.(message.payload);
break;
case 'error':
console.error('[DoubaoVoice] Error:', message.payload);
break;
}
}
}
// Usage example
const client = new DoubaoVoiceClient({
wsUrl: 'ws://localhost:5000/ws',
params: {
appId: 'your-app-id',
accessToken: 'your-access-token',
sampleRate: 16000,
bitsPerSample: 16,
channels: 1
}
});

Using AudioWorkletNode for audio processing gives better performance:

audio-worklet.js
class AudioProcessorWorklet extends AudioWorkletProcessor {
process(inputs, outputs, parameters) {
const input = inputs[0]?.[0];
if (!input) return true;
// Convert to 16-bit PCM
const pcm = new Int16Array(input.length);
for (let i = 0; i < input.length; i++) {
pcm[i] = Math.max(-32768, Math.min(32767, input[i] * 32767));
}
this.port.postMessage({
type: 'audioData',
data: pcm.buffer
}, [pcm.buffer]);
return true;
}
}
registerProcessor('audio-processor', AudioProcessorWorklet);
// Main thread code
async function startAudioRecording() {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
sampleRate: 48000
}
});
const audioContext = new AudioContext();
const audioSource = audioContext.createMediaStreamSource(stream);
await audioContext.audioWorklet.addModule('/audio-worklet.js');
const audioWorkletNode = new AudioWorkletNode(audioContext, 'audio-processor');
audioWorkletNode.port.onmessage = (event) => {
if (event.data.type === 'audioData' && ws?.readyState === WebSocket.OPEN) {
ws.send(event.data.data); // Send binary data directly
}
};
audioSource.connect(audioWorkletNode);
}

AudioWorklet performs far better than ScriptProcessorNode and avoids the audio stutter problems that older processing paths often introduce.

{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{ "Name": "Console" },
{
"Name": "File",
"Args": { "path": "logs/log-.txt", "rollingInterval": "Day" }
}
]
},
"Kestrel": {
"Urls": "http://0.0.0.0:5000"
}
}

Logging configuration matters because it makes problems much easier to trace. Serilog’s file sink can roll logs daily, which keeps individual log files at a manageable size.

  • Regularly log session state to trace the full connection lifecycle
  • Monitor the number and duration of audio segments to detect abnormal connections
  • Record connection status and reconnection behavior for the Doubao service

These are basic operational practices, but they make a real difference in production.

  • Capture and log all WebSocket exceptions
  • Use IAsyncDisposable to ensure resources are cleaned up
  • Implement graceful connection shutdown and timeout handling

In short, favor robustness.

  • Sample rate: 16000 Hz (recommended) or 8000 Hz
  • Bit depth: 16-bit
  • Channels: mono
  • Encoding: PCM (raw)

If the format is wrong, recognition may fail or quality may degrade significantly. These requirements matter.

  • Keep sensitive credentials only in backend configuration
  • Enforce connection limits to prevent resource exhaustion
  • Use HTTPS/WSS in production

Security is never a minor concern.

  • Use asynchronous operations to avoid blocking
  • Tune buffer sizes as needed (default: 4096 bytes)
  • Consider connection pooling and reuse strategies

Apply these optimizations where they make sense for your workload.

  1. Docker deployment: Package the proxy service as a container for easier scaling and management
  2. Load balancing: Use Nginx or Envoy as a reverse proxy for WebSocket traffic
  3. Health checks: Implement heartbeat-based checks to monitor service availability
  4. Log aggregation: Send logs to a centralized logging system such as ELK or Loki

Deployment can be simple or complex depending on the team and environment, so adjust accordingly.

The WebSocket proxy pattern solves the core problem that the browser WebSocket API does not support custom headers. In the HagiCode project, this pattern proved both feasible and stable as it moved from playground validation into production deployment.

Key takeaways:

  • A backend proxy can pass authentication information securely
  • Native WebSocket is lightweight and efficient for simple scenarios
  • The “one connection, one session” model simplifies both implementation and debugging
  • The frontend-backend protocol should separate control signals from audio data

If you are building a feature that also depends on WebSocket authentication, I hope this pattern gives you a useful starting point.

If you have questions, feel free to discuss them with us. Technical progress happens faster when people compare notes.


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 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.

A Complete Practical Guide to Integrating Microsoft Clarity into a Starlight Documentation Site

From Data Insight to User Growth: A Complete Guide to Integrating Clarity Analytics into the HagiCode Blog

Section titled “From Data Insight to User Growth: A Complete Guide to Integrating Clarity Analytics into the HagiCode Blog”

This article shares how to elegantly integrate Microsoft Clarity into a Starlight documentation site so you can clearly understand user behavior while still staying privacy-compliant. This solution is distilled from our implementation experience in the HagiCode project, and we hope it gives some useful reference to anyone else wrestling with analytics.

The following code shows how to dynamically inject the Microsoft Clarity script in an Astro integration based on environment variables, loading it in production only when it is actually enabled.

105 | interface Props {
106 | // Future extension: allow manually overriding the Project ID
107 | projectId?: string;
108 | }
109 |
110 | const {
111 | projectId = import.meta.env.CLARITY_PROJECT_ID,
112 | } = Astro.props;
113 |
114 | const isProduction = import.meta.env.PROD;
115 | ---
116 |
117 | {isProduction && projectId && (
118 | <script is:inline define:vars={{projectId}}>
119 | (function(c,l,a,r,i,t,y){

File: openspec/changes/archive/2026-01-30-microsoft-clarity-integration/design.md

While operating HagiCode, we kept running into a “black box” problem: we were producing content, but we had no clear view of how users were actually reading it. GitHub Stars can show some signal, but that feedback comes far too late. What we really needed to know was:

  • Do users actually finish reading our tutorials?
  • At which step do those complicated configuration docs drive people away?
  • Is our SEO optimization really bringing in meaningful traffic?

There are many analytics tools on the market, such as Google Analytics (GA) and Microsoft Clarity. GA is powerful but more complex to configure, and it is also more tightly constrained by privacy regulations such as GDPR. Clarity, as Microsoft’s free heatmap tool, is not only intuitive to use but also relatively easier to work with from a privacy-compliance perspective, making it a great fit for technical documentation sites.

Our goal was very clear: seamlessly integrate Clarity into the HagiCode documentation site so that it works across all pages while still giving users the right to opt out for privacy compliance.

HagiCode theme initialization logic: first read from local storage, then fall back to system preference, with dark mode as the default.

67 | function getInitialTheme(): Theme {
68 | // 1. Check localStorage
69 | const stored = localStorage.getItem('hagicode-theme');
70 | if (stored) return stored as Theme;
71 |
72 | // 2. Detect system preference
73 | const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
74 | if (systemDark) return 'dark';
75 |
76 | // 3. Default to dark mode
77 | return 'dark';
78 | }
79 | ```
80 |
81 | ### Decision 3: Theme application method
82 |
83 | **Choice**: set the `data-theme` attribute on the `<html>` root element
84 |
85 | **Alternative options**:
86 |

File: openspec/changes/archive/2026-01-29-theme-toggle-implementation/design.md

The approach shared in this article comes from our hands-on experience in the HagiCode project. HagiCode is an AI-based coding assistant tool, and during development we need to maintain a large body of technical documentation and blog content. To better understand user needs, we explored and implemented this analytics integration solution.

At first, we discussed several integration approaches during the Proposal phase. Since we were using Starlight, a documentation framework built on Astro, the most obvious idea was to use Astro Hooks.

We first tried modifying astro.config.mjs and planned to inject the Clarity script during the build. Although that approach could guarantee global coverage, it lacked flexibility: we could not dynamically load or unload the script based on user preference.

Taking user experience and privacy control into account, we ultimately chose a component override approach. Starlight allows developers to override its internal components, which means we could take over the rendering logic for <footer> or <head> and precisely control when Clarity is loaded.

There was also a small detour here: originally, we wanted to create a layout wrapper named StarlightWrapper.astro. But during real debugging, we found that Starlight’s routing mechanism does not automatically invoke this custom wrapper, causing the script to fail on some pages. It was a classic “obvious assumption” pitfall and reminded us that we must deeply understand the framework’s rendering flow instead of blindly applying generic framework patterns.

Section titled “Core Solution: Overriding the Footer Component”

To ensure the Clarity script loads on all pages, including docs and blog posts, without breaking the original page structure, we chose to override Starlight’s Footer component.

  1. Global presence: the Footer appears on almost all standard pages.
  2. Non-intrusive: placing the script in the Footer area, which is actually rendered near the bottom of the body, does not block the page’s critical rendering path (LCP), so the performance impact is minimal.
  3. Centralized logic: cookie consent logic can be handled in one place inside the component.

First, register at Microsoft Clarity and create a new project. Then get your Project ID, which looks like a string such as k8z2ab3xxx.

The following demonstrates environment variable configuration and date-checking logic to implement conditional behavior during the Lunar New Year period. Please refer to the concrete implementation.

46 | function isLunarNewYearPeriod() {
47 | const now = new Date();
48 | const year = now.getFullYear();
49 | const month = now.getMonth() + 1; // 1-12
50 | const day = now.getDate();
51 |
52 | // Lunar New Year period in 2025, Year of the Snake (January 29 - February 12)
53 | if (year === 2025) {
54 | if (month === 1 && day >= 29) return true;
55 | if (month === 2 && day <= 12) return true;
56 | }
57 | // Lunar New Year period in 2026, Year of the Horse (February 17 - March 3)
58 | if (year === 2026) {
59 | if (month === 2 && day >= 17) return true;
60 | if (month === 3 && day <= 3) return true;
61 | }
62 | return false;
63 | }
64 |
65 | const stored = localStorage.getItem('starlight-theme');

File: src/pages/index.astro

For safety, do not hardcode the ID. It is recommended to store it in an environment variable.

Create a .env file in the project root:

Terminal window
# Microsoft Clarity ID
PUBLIC_CLARITY_ID="your_Clarity_ID"

The following is an implementation that listens for system theme changes, showing how to follow the system theme only when the user has not set one manually.

445 | const handleChange = (e: MediaQueryListEvent) => {
446 | // Only follow the system when the user has not manually set a theme
447 | if (!localStorage.getItem(THEME_KEY)) {
448 | setThemeState(e.matches ? 'dark' : 'light');
449 | }
450 | };
451 |
452 | mediaQuery.addEventListener('change', handleChange);
453 | return () => mediaQuery.removeEventListener('change', handleChange);
454 | }, []);
455 |
456 | return { theme, toggleTheme, setTheme: manuallySetTheme };
457 | }
458 | ```
459 |
460 | #### 3. `src/components/ThemeButton.tsx` - Button component
461 |
462 | **Responsibility**: render the theme toggle button and handle user interaction
463 |
464 | **Component interface**:

File: openspec/changes/archive/2026-01-29-theme-toggle-implementation/design.md

Create a file named StarlightFooter.astro under src/components/. Starlight will automatically recognize it and override the default Footer.

The core code logic is as follows:

src/components/StarlightFooter.astro
---
// 1. Import the original component to preserve its default behavior
import DefaultFooter from '@astrojs/starlight/components/StarlightFooter.astro';
// 2. Read the environment variable
const clarityId = import.meta.env.PUBLIC_CLARITY_ID;
// 3. Define a simple injection script (inline approach)
// Note: in production, it is recommended to move this logic into a separate .js file to take advantage of caching
const initScript = `
(function(c,l,a,r,i,t,y){
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window, document, "clarity", "script", "${clarityId}");
`;
---
<DefaultFooter {...Astro.props} />
{/* Inject the script only in production and only when the ID exists */}
{import.meta.env.PROD && clarityId && (
<script is:inline define:vars={{ clarityId }}>
{initScript}
</script>
)}

Key points explained:

  • is:inline: tells Astro not to process the contents of this script tag and to output it directly into the HTML. This is critical for third-party analytics scripts; otherwise, Astro’s bundling optimization may cause the script to stop working.
  • define:vars: an Astro 3+ feature that allows variables to be injected safely within scope.
  • import.meta.env.PROD: ensures no meaningless analytics are generated during local development unless explicitly needed for debugging, keeping your data clean.
Section titled “Advanced Topic: Privacy Compliance and Cookie Control”

Simply adding the code is not enough, especially in GDPR-regulated regions. We need to respect user choice.

HagiCode’s approach is to provide a simple toggle. While this is not a full-featured cookie banner, for a technical documentation site that mainly serves content, such cookies are often categorized as “necessary” or “analytics” cookies and can be disclosed in the privacy statement and enabled by default, or linked from the Footer to a privacy settings page.

If you need stricter control, you can combine it with localStorage to record the user’s choice:

This article will introduce TypeScript utility functions used for theme switching and persistence, using type safety and environment detection for stricter control.

367 | export function getInitialTheme(): Theme;
368 | export function getSystemTheme(): Theme;
369 | export function setTheme(theme: Theme): void;
370 | export function applyTheme(theme: Theme): void;
371 | ```
372 |
373 | **Design principles**:
374 | - **Pure functions**: no side effects, except for `setTheme` and `applyTheme`
375 | - **Type safety**: complete TypeScript type inference
376 | - **Environment detection**: SSR-safe (`typeof window` check)
377 | - **Single responsibility**: each function does only one thing
378 |
379 | **Key implementation**:
380 | ```typescript
381 | export function getInitialTheme(): Theme {
382 | if (typeof window === 'undefined') return 'dark';
383 |
384 | const stored = localStorage.getItem(THEME_KEY);
385 | if (stored === 'light' || stored === 'dark') return stored;
386 |

File: openspec/changes/archive/2026-01-29-theme-toggle-implementation/design.md

// Simple example: check whether the user has declined analytics
const consent = localStorage.getItem('clarity_consent');
if (consent !== 'denied') {
// Run the Clarity initialization code above
window.clarity('start', clarityId);
}

As we rolled this solution out in HagiCode, we summarized several details that are easy to overlook:

  1. StarlightWrapper.astro is a trap: As mentioned earlier, do not try to create a global Wrapper to inject the script. That approach does not work in Starlight. The correct solution is to override a specific component such as StarlightFooter.astro or StarlightHead.astro.

  2. Performance considerations for script placement: Although Clarity recommends placing the script in <head> to ensure maximum data accuracy, for a documentation site, first-screen loading speed (LCP) directly affects SEO and user retention. We chose to place it in the Footer, near the bottom of the body. This may slightly miss a tiny amount of “bounce in seconds” user data, but it gives us a faster page-loading experience, which is a worthwhile trade-off.

  3. Interference from the development environment: Be sure to add the import.meta.env.PROD check. In development mode, you will refresh pages frequently, which would otherwise generate a large amount of meaningless test data and pollute your Clarity dashboard.

After deployment, you can view real-time data in the Clarity console. Usually within a few minutes, you will start seeing user heatmaps and recordings.

For HagiCode, this data helped us discover that:

  • many users repeatedly revisit the “Quick Start” section, suggesting that our installation guidance may still not be intuitive enough.
  • the “API Reference” page has the longest dwell time, confirming the needs of our core user group.

Integrating Microsoft Clarity does not require complex server-side changes, nor does it require bringing in a heavy SDK.

By taking advantage of Starlight’s component override mechanism, we achieved site-wide analytics with nothing more than a lightweight StarlightFooter.astro component. This kind of “micro-integration” keeps the codebase clean while giving us the ability to understand user behavior.

If you are also operating a technical project, especially one like HagiCode where the documentation needs to keep evolving, I strongly recommend trying Clarity. The data will tell you where users are really struggling.


Thank you for reading. If you found this article useful, 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.

HagiCode Splash Screen Design: The Ultimate Way to Fill the Hydration Gap in React 19 Apps

Designing 12 Exceptional Startup Experiences for HagiCode: From Minimalism to Cyberpunk

Section titled “Designing 12 Exceptional Startup Experiences for HagiCode: From Minimalism to Cyberpunk”

The brief gap between downloading a React 19 app and completing Hydration is a golden opportunity for users to feel your brand personality. In this article, we share a complete startup style system we built for the HagiCode project using HTML/CSS/JS.

As a modern application built with ASP.NET Core 10 and React 19 (Vite), HagiCode uses a frontend-backend separated deployment architecture. The frontend build output is packaged into the backend wwwroot/ directory and hosted by ASP.NET Core.

However, this architecture introduces a classic UX pain point: when users visit the page, the browser first loads the HTML, then downloads the large JS bundle, and finally lets React perform Hydration. During this “vacuum period” that lasts from a few hundred milliseconds to several seconds, users see either a blank screen or a lifeless static page.

To fill that gap and inject HagiCode’s brand personality, we needed to design a startup style system implemented entirely with inline code inside index.html.

The splash screen design approach shared in this article comes from our practical experience in the HagiCode project. As an AI coding assistant, HagiCode cares not only about code generation efficiency, but also about the developer’s visual experience. This startup system is one of the outcomes of our pursuit of ultimate frontend performance.

Before we started designing, we first had to clarify the technical constraints. Since everything had to be implemented inline in index.html, we could not load any external CSS or JS files other than React’s own bundle.

  1. Zero-dependency principle: All styles must live inside a <style> tag, and all logic must live inside a <script> tag.
  2. Defensive CSS: To prevent global styles from polluting the splash screen after the React app mounts, we decided to wrap all startup styles with a high-priority ID prefix such as #boot-screen.
  3. Performance first: Animations should use CSS transform and opacity wherever possible to avoid reflow and ensure the main thread stays unblocked.
  4. Visual consistency: Colors and fonts must stay aligned with HagiCode’s Tailwind configuration.

We adopted a variant pattern. The core logic is encapsulated inside an immediately invoked function expression (IIFE), while the specific rendering logic is injected through configuration. This lets us switch between different styles through simple configuration instead of rewriting DOM manipulation logic repeatedly.

Here is the core architecture code:

<!-- Inline in index.html -->
<div id="boot-root"></div>
<script>
(function() {
const BootSequence = {
config: {
theme: 'terminal', // Configurable as 'minimal', 'skeleton', 'code-rain', etc.
color: '#3b82f6' // Brand color
},
// Core lifecycle
init() {
this.render();
this.listenForMount();
},
// Render the currently selected style
render() {
const root = document.getElementById('boot-root');
if (this.variants[this.config.theme]) {
root.innerHTML = this.variants[this.config.theme].render();
}
},
// Listen for successful React mount and exit gracefully
listenForMount() {
window.addEventListener('hagicode:ready', () => {
const screen = document.getElementById('boot-root');
// Fade out first, then remove the DOM to avoid flicker
screen.style.opacity = '0';
screen.style.transition = 'opacity 0.3s ease';
setTimeout(() => screen.remove(), 300);
});
},
// The implementation logic for all 12 styles lives here
variants: {
// ...see details below
}
};
BootSequence.init();
})();
</script>

We grouped these 12 styles into six major categories to satisfy different scenarios and aesthetic preferences.

“Less is more.” For scenarios that pursue ultimate loading speed, we provide the lightest possible options.

A simple dot sits at the center of the screen, paired with a breathing animation.

  • Implementation: CSS @keyframes controls scale and opacity.
  • Best for: Any case where the page must remain absolutely clean.

Using SVG stroke-dasharray animation, it simulates a hand-drawn reveal of the HagiCode logo lines, followed by a text fade-in.

  • Technique: SVG path animation with a highly polished feel.

“The art of deceiving the eye.” By simulating a real UI layout, users feel like the page is already half loaded.

This may be the most practical option. We manually built a layout in HTML that mirrors the React Sidebar and ChatInput components exactly, then overlaid it with a gray shimmer animation.

  • Value: When React hydration completes, the skeleton instantly becomes the real component, and users can barely perceive the switch.

It simulates the stacked motion of proposal cards while loading, using 3D transforms to make the cards float subtly.

Show off HagiCode’s geek DNA.

A geometric shape (a square) is rendered at the center of the screen, then smoothly transforms over time into a circle, a triangle, and finally the logo.

  • Technology: Smooth transitions with CSS border-radius.

A tribute to The Matrix. Using the JetBrains Mono font, faint streams of characters fall in the background.

  • Note: For performance, the character streams must stay within a smaller area or use a lower refresh rate.

A cyberpunk-style glowing ring that uses multiple box-shadow layers to create a powerful neon glow.

Make the system feel alive.

This is a dynamic loader. It checks the current date for holidays such as Lunar New Year or Christmas and loads the corresponding SVG animation.

  • Example: During Lunar New Year, red lanterns gently sway at the bottom of the screen.

The background uses a fluid gradient based on HagiCode brand colors. Combined with animated background-size and background-position, it creates an aurora-like sense of motion.

A salute to developers.

It simulates console output. Lines of code scroll by rapidly:

> Initializing HagiCode Core...
> Loading models...
> Connecting to neural network...

That instantly feels familiar to every developer.

A thin progress bar appears at the top of the screen, with a percentage shown on the right. While we cannot access the real download progress, we can use a timer to simulate a “believable” loading process: fast for the first 80%, then gradually slower for the last 20%.

This is a very interesting idea. Small squares are scattered across the screen, then converge toward the center and gradually assemble into the HagiCode logo icon. It symbolizes the process of building code.

In HagiCode’s real development work, we summarized several critically important implementation details.

Never get lazy and skip the prefix. Once, we forgot to scope the splash screen styles with an ID, and global div styles after React mounted unexpectedly affected the splash screen, breaking the layout. Lesson learned: Put every CSS selector under #boot-screen, and use !important to raise priority when necessary, but only inside the splash screen CSS.

After React mounts successfully, do not directly remove() the splash screen DOM. Correct approach:

  1. React triggers window.dispatchEvent(new Event('hagicode:ready')).
  2. The splash screen listens for the event and first sets opacity: 0.
  3. Wait 300ms, which matches the CSS transition duration, and call .remove() only after the screen is fully invisible.

The splash screen color values are hard-coded in index.html. If we change Tailwind’s primary color, we must update the splash screen too. Optimization: Write a simple plugin in the Vite build script to read tailwind.config.js and inject color variables into the index.html template variables, creating a single source of truth.

Splash screens often need to use a brand font, but if the font loads slowly, FOUT (Flash of Unstyled Text) can appear. Solution: Add <link rel="preload" href="/fonts/JetBrainsMono.woff2" as="font" type="font/woff2" crossorigin> inside <head>. This is a low-cost, high-return way to improve the experience.

We injected performance.mark('boot-start') at the bottom of index.html, and marked boot-end when React mounted successfully. Why it matters: By collecting this data through Application Insights, we can directly measure how much the splash screen shortens perceived waiting time. The data shows that an excellent skeleton screen can improve users’ tolerance for a “slow network” by more than 50%.

A good splash screen is more than “decoration while waiting”. It is the handshake signal in the very first interaction between the product and the user. In the HagiCode project, this startup system based on the Variants pattern lets us flexibly switch styles across holidays and releases, greatly enhancing the product’s sense of fun and professionalism.

The solution shared in this article is built entirely on native web standards without introducing any heavy dependencies, which reflects HagiCode’s pursuit of being “lightweight yet powerful.” If you find this approach valuable, feel free to check out the source code in the HagiCode repository and even contribute your own creative designs.

If this article helped you, feel free to give the project a Star on GitHub. The public beta has already started, and we look forward to your feedback!


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.

.NET Core Dual-Database in Practice: Best Practices for Elegantly Combining PostgreSQL and SQLite

.NET Core Dual-Database in Practice: Let PostgreSQL and SQLite Coexist Peacefully

Section titled “.NET Core Dual-Database in Practice: Let PostgreSQL and SQLite Coexist Peacefully”

When building modern applications, we often face this trade-off: development environments want something lightweight and convenient, while production environments demand high concurrency and high availability. This article shares how to elegantly support both PostgreSQL and SQLite in a .NET Core project and implement the best practice of “SQLite for development, PostgreSQL for production.”

In software development, differences between environments have always been one of the hardest problems for engineering teams. Take the HagiCode platform we are building as an example: it is an AI-assisted development system based on ASP.NET Core 10 and React, with Orleans integrated internally for distributed state management. The stack is modern and fairly sophisticated.

Early in the project, we ran into a classic engineering pain point: developers wanted the local environment to work out of the box, without having to install and configure a heavy PostgreSQL database. But in production, we needed to handle high-concurrency writes and complex JSON queries, and that is exactly where lightweight SQLite starts to show its limits.

How can we keep a single codebase while allowing the application to benefit from SQLite’s portability like a desktop app, and also leverage PostgreSQL’s powerful performance like an enterprise-grade service? That is the core question this article explores.

The dual-database adaptation approach shared in this article comes directly from our hands-on experience in the HagiCode project. HagiCode is a next-generation development platform that integrates AI prompt management and the OpenSpec workflow. It was precisely to balance developer experience with production stability that we arrived at this proven architectural pattern.

Feel free to visit our GitHub repository to see the full project: HagiCode-org/site.

Core Topic 1: Architecture Design and Unified Abstraction

Section titled “Core Topic 1: Architecture Design and Unified Abstraction”

To support two databases in .NET Core, the key idea is to depend on abstractions rather than concrete implementations. We need to separate database selection from business code and let the configuration layer decide.

  1. Unified interface: All business logic should depend on the DbContext base class or custom interfaces, rather than a specific PostgreSqlDbContext.
  2. Configuration-driven: Use configuration items in appsettings.json to dynamically decide which database provider to load at application startup.
  3. Feature isolation: Add adaptation logic for PostgreSQL-specific capabilities, such as JSONB, so the application can still degrade gracefully on SQLite.

Code Implementation: Dynamic Context Configuration

Section titled “Code Implementation: Dynamic Context Configuration”

In ASP.NET Core’s Program.cs, we should not hard-code UseNpgsql or UseSqlite. Instead, we should read configuration and decide dynamically.

First, define the configuration class:

public class DatabaseSettings
{
public const string SectionName = "Database";
// Database type: PostgreSQL or SQLite
public string DbType { get; set; } = "PostgreSQL";
// Connection string
public string ConnectionString { get; set; } = string.Empty;
}

Then register the service in Program.cs based on configuration:

// Read configuration
var databaseSettings = builder.Configuration.GetSection(DatabaseSettings.SectionName).Get<DatabaseSettings>();
// Register DbContext
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
if (databaseSettings?.DbType?.ToLower() == "sqlite")
{
// SQLite configuration
options.UseSqlite(databaseSettings.ConnectionString);
// Handling SQLite's concurrent write limitations
// Note: in production, enabling WAL mode is recommended to improve concurrency performance
}
else
{
// PostgreSQL configuration (default)
options.UseNpgsql(databaseSettings.ConnectionString, npgsqlOptions =>
{
// Enable JSONB support, which is very useful when handling AI conversation records
npgsqlOptions.UseJsonNet();
});
// Configure connection pool retry policy
options.EnableRetryOnFailure(3);
}
});

Core Topic 2: Handling Differences and Migration Strategy

Section titled “Core Topic 2: Handling Differences and Migration Strategy”

Although PostgreSQL and SQLite both support the SQL standard, they differ significantly in specific capabilities and behavior. If these differences are not handled carefully, you can easily end up with the awkward situation where everything works locally but fails after deployment.

In HagiCode, we need to store a large amount of prompts and AI metadata, which usually involves JSON columns.

  • PostgreSQL: Has a native JSONB type with excellent query performance.
  • SQLite: Does not have a native JSON type (newer versions include the JSON1 extension, but object mapping still differs), so data is usually stored as TEXT.

Solution: In EF Core entity mapping, we configure it as a convertible type.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Configure entity
modelBuilder.Entity<PromptTemplate>(entity =>
{
entity.Property(e => e.Metadata)
.HasColumnType("jsonb") // PG uses jsonb
.HasConversion(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions)null),
v => JsonSerializer.Deserialize<Dictionary<string, object>>(v, (JsonSerializerOptions)null)
);
});
}

When SQLite is used, HasColumnType("jsonb") may be ignored or trigger a warning. However, because HasConversion is configured, the data is still serialized and deserialized correctly as strings stored in a TEXT field, ensuring compatibility.

Never try to make one set of Migration scripts work for both PostgreSQL and SQLite at the same time. Differences in primary key generation strategies, index syntax, and other database details will inevitably cause failures.

Recommended practice: Maintain two migration branches or projects. In the HagiCode development workflow, this is how we handle it:

  1. Development stage: Work mainly with SQLite. Use Add-Migration Init_Sqlite -OutputDir Migrations/Sqlite.
  2. Adaptation stage: After developing a feature, switch the connection string to PostgreSQL and run Add-Migration Init_Postgres -OutputDir Migrations/Postgres.
  3. Automation scripts: Write a simple PowerShell or Bash script to automatically apply the correct migration based on the current environment variable.
Terminal window
# Pseudocode for simple deployment logic
if [ "$DATABASE_PROVIDER" = "PostgreSQL" ]; then
dotnet ef database update --project Migrations.Postgres
else
dotnet ef database update --project Migrations.Sqlite
fi

Core Topic 3: Lessons Learned from HagiCode in Production

Section titled “Core Topic 3: Lessons Learned from HagiCode in Production”

While refactoring HagiCode from a single-database model to dual-database support, we hit a few pitfalls and gathered some important lessons that may help you avoid the same mistakes.

1. Differences in Concurrency and Transactions

Section titled “1. Differences in Concurrency and Transactions”

PostgreSQL uses a server-client architecture and supports high-concurrency writes with powerful transaction isolation levels. SQLite uses file locking, so write operations lock the entire database file unless WAL mode is enabled.

Recommendation: When writing business logic that involves frequent writes, such as real-time saving of a user’s editing state, you must take SQLite’s locking model into account. When designing the OpenSpec collaboration module in HagiCode, we introduced a “merge before write” mechanism to reduce the frequency of direct database writes, allowing us to maintain good performance on both databases.

2. Lifecycle Management of Connection Strings

Section titled “2. Lifecycle Management of Connection Strings”

Establishing a PostgreSQL connection is relatively expensive and depends on connection pooling. SQLite connections are very lightweight, but if they are not released promptly, file locks may cause later operations to time out.

In Program.cs, we can fine-tune behavior for each database:

if (databaseSettings?.DbType?.ToLower() == "sqlite")
{
// SQLite: keeping connections open can improve performance, but watch out for file locks
options.UseSqlite(connectionString, sqliteOptions =>
{
// Set command timeout
sqliteOptions.CommandTimeout(30);
});
}
else
{
// PG: make full use of connection pooling
options.UseNpgsql(connectionString, npgsqlOptions =>
{
npgsqlOptions.MaxBatchSize(100);
npgsqlOptions.CommandTimeout(30);
});
}

Many developers, including some early members of our team, tend to make one common mistake: running unit tests only in the development environment, which is usually SQLite.

In HagiCode’s CI/CD pipeline, we enforced a GitHub Actions step to make sure every pull request runs PostgreSQL integration tests.

# Example snippet from .github/workflows/test.yml
- name: Run Integration Tests (PostgreSQL)
run: |
docker-compose up -d db_postgres
dotnet test --filter "Category=Integration"

This helped us catch countless bugs related to SQL syntax differences and case sensitivity.

By introducing an abstraction layer and configuration-driven dependency injection, we successfully implemented a dual-track PostgreSQL and SQLite setup in the HagiCode project. This not only greatly lowered the onboarding barrier for new developers by removing the need to install PostgreSQL, but also provided strong performance guarantees for production.

To recap the key points:

  1. Abstraction first: Business code should not depend on concrete database implementations.
  2. Separate configuration: Use different appsettings.json files for development and production.
  3. Separate migrations: Do not try to make one Migration set work everywhere.
  4. Feature degradation: Prioritize compatibility in SQLite and performance in PostgreSQL.

This architectural pattern is not only suitable for HagiCode, but for any .NET project that needs to strike a balance between lightweight development and heavyweight production.


If this article helped you, feel free to give us a Star on GitHub, or experience the efficient development workflow brought by HagiCode directly:

The public beta has started. Welcome to install it and give it a try!


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.

Docusaurus 3.x to Astro 5.x Migration in Practice: Using Islands Architecture to Improve Both Performance and Build Speed

From Docusaurus 3.x to Astro 5.x: A Retrospective on the HagiCode Site Migration

Section titled “From Docusaurus 3.x to Astro 5.x: A Retrospective on the HagiCode Site Migration”

This article looks back on our full migration of the HagiCode official website from Docusaurus 3.x to Astro 5.x. We will take a deep dive into how Astro’s Islands Architecture helped us solve performance bottlenecks while preserving our existing React component assets, delivering improvements in both build speed and loading performance.

In January 2026, we performed a “heart transplant” on the HagiCode official site by fully migrating its core framework from Docusaurus 3.x to Astro 5.x. This was not an impulsive rewrite, but a carefully considered technical decision.

Before the migration, our site was functionally complete, but it had begun to show some classic “luxury problems”: bloated build artifacts, excessive JavaScript payloads, and less-than-ideal page load speed on complex documentation pages. As an AI coding assistant project, HagiCode needs frequent documentation and feature updates, so build efficiency directly affects release speed. At the same time, we wanted the site to be more search-engine-friendly (SEO) so more developers could discover the project.

To solve these pain points, we made a bold decision: rebuild the entire system on Astro. The impact of that decision may be even bigger than you expect. I will get into the details shortly.

The site migration approach shared in this article comes from our hands-on experience in the HagiCode project.

HagiCode is an AI coding assistant focused on improving development efficiency. We care not only about iterating on core features, but also about the developer experience. This site refactor was also meant to give users the fastest possible experience when browsing our docs and official website.

Why Leave the Mature Docusaurus Ecosystem?

Section titled “Why Leave the Mature Docusaurus Ecosystem?”

Within the React ecosystem, Docusaurus has long been the “standard answer” for documentation sites. It works out of the box, offers a rich plugin ecosystem, and has an active community. But as HagiCode gained more features, we also felt its limitations:

  1. Performance bottlenecks: Docusaurus is fundamentally a React SPA (single-page application). Even if you only write static pages, the client still needs to load the React runtime and hydrate the page, which is unnecessarily heavy for simple docs pages.
  2. Large asset size: Even when a page contains very little content, the bundled JS size stays relatively fixed. That is not ideal for mobile users or poor network conditions.
  3. Limited flexibility: Although it is extensible, we wanted more low-level control over the build pipeline.

Astro arrived at exactly the right time to solve these problems. It introduced a new “Islands Architecture”: by default, Astro generates static HTML with zero JavaScript, and only components that require interactivity are “activated” and load JS. That means most of our site becomes pure HTML and loads extremely fast.

Core Migration Strategy: A Smooth Architectural Transition

Section titled “Core Migration Strategy: A Smooth Architectural Transition”

Migration was not just copy and paste. It required a shift in mindset. We moved from Docusaurus’s “all React” model to Astro’s “Core + Islands” model.

First, we had to move from docusaurus.config.ts to astro.config.mjs. This was not just a file rename, but a rewrite of routing and build logic.

In Docusaurus, everything is a plugin. In Astro, everything is an integration. We needed to redefine the site’s base path, build output mode (static vs SSR), and asset optimization strategy.

Before migration:

docusaurus.config.ts
export default {
title: 'HagiCode',
url: 'https://hagicode.com',
baseUrl: '/',
// ... more configuration
};

After migration:

astro.config.mjs
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
export default defineConfig({
integrations: [react()],
site: 'https://hagicode.com',
base: '/',
// Optimization settings for static assets
build: {
inlineStylesheets: 'auto',
},
});

2. What to Keep and What to Refactor in React Components

Section titled “2. What to Keep and What to Refactor in React Components”

This was the most painful part of the migration. Our existing site had many React components, such as Tabs, code highlighting, feedback buttons, and more. Throwing them away would be wasteful, but keeping everything would make the JavaScript payload too heavy.

HagiCode adopted a progressive hydration strategy:

  • Pure static components: For presentational content such as headers, footers, and plain text documentation, we rewrote them as Astro components (.astro files) and rendered them directly to HTML at build time.
  • Interactive islands: For components that must remain interactive, such as theme switchers, tab switching, and code block copy buttons, we kept the React implementation and added client:load or client:visible directives.

For example, our commonly used Tabs component in the documentation:

src/components/Tabs.jsx
import { useState } from 'react';
import './Tabs.css'; // Import styles
export default function Tabs({ items }) {
const [activeIndex, setActiveIndex] = useState(0);
// ... state logic
return (
<div className="tabs-wrapper">
{/* Rendering logic */}
</div>
);
}

When used in Markdown, we explicitly tell Astro: “This component needs JS.”

src/content/docs/example.mdx
import Tabs from '../../components/Tabs.jsx';
<!-- Load JS only when the component enters the viewport -->
<Tabs client:visible items={...} />

This way, interactive components outside the viewport do not compete for bandwidth, which greatly improves first-screen loading speed.

3. Adapting the Styling System: From CSS Modules to Scoped CSS

Section titled “3. Adapting the Styling System: From CSS Modules to Scoped CSS”

Docusaurus supports CSS Modules by default, while Astro encourages Scoped CSS through the <style> tag. The core idea behind both is style isolation, but the syntax is different.

During the HagiCode migration, we converted most complex CSS Modules into Astro’s scoped styles. This actually turned out to be a good thing, because in .astro files the styles and templates live in the same file, which makes maintenance more intuitive.

Before refactoring:

Tabs.module.css
.wrapper { background: var(--ifm-background-color); }

After refactoring (Astro Scoped):

Tabs.astro
<div class="tabs-wrapper">
<slot />
</div>
<style>
.tabs-wrapper {
/* Use CSS variables directly to adapt to the theme */
background: var(--bg-color);
padding: 1rem;
}
</style>

At the same time, we unified the global CSS variable system and used Astro’s environment-aware capabilities to ensure dark mode switches smoothly across pages.

Pitfalls We Hit in Practice and How We Solved Them

Section titled “Pitfalls We Hit in Practice and How We Solved Them”

During the actual HagiCode migration, we ran into quite a few issues. Here are several of the most typical ones.

1. Path and Environment Variable Pain Points

Section titled “1. Path and Environment Variable Pain Points”

HagiCode supports subpath deployment, such as deployment under a GitHub Pages subdirectory. In Docusaurus, baseUrl is handled automatically. In Astro, however, we need to be more careful when handling image links and API requests.

We introduced an environment variable mechanism to manage this consistently:

// Handle paths in the build script
const getBasePath = () => import.meta.env.VITE_SITE_BASE || '/';

Be sure not to hardcode paths beginning with / in your code. In development versus production, or after configuring a base path, doing so can cause 404s for assets.

Our old site had some Node.js scripts used for tasks such as automatically fetching Metrics data and updating the sitemap, and they were written in CommonJS (require). Astro and modern build tools have fully embraced ES Modules (import/export).

If you also have similar scripts, remember to refactor them all to ES Modules. That is the direction the ecosystem is moving, and the sooner you make the change, the less trouble you will have later.

// Old way
const fs = require('fs');
// New way
import fs from 'fs';

Search engines have already indexed HagiCode’s old Docusaurus pages. If you switch directly to Astro and the URL structure changes, you may end up with a large number of 404s and a major drop in search ranking.

We configured redirect rules in Astro:

astro.config.mjs
export default defineConfig({
redirects: {
'/docs/old-path': '/docs/new-path',
// Map old links to new links in bulk
}
});

Or you can handle this at the server configuration layer. Make sure old links can be 301 redirected to the new addresses, because this is critical for SEO.

For HagiCode, migrating from Docusaurus to Astro was not just a framework upgrade. It was also a practical implementation of a “performance first” philosophy.

What we gained:

  • Outstanding Lighthouse scores: After the migration, the HagiCode site’s performance score easily approached a perfect score.
  • Faster build speed: Astro’s incremental build capabilities cut the release time for documentation updates in half.
  • Preserved flexibility: With Islands Architecture, we did not sacrifice any interactive features and could still use React where needed.

If you are also maintaining a documentation-oriented site and are struggling with bundle size or load speed, Astro is well worth trying. Although the migration process does require some surgery, such as renaming PCode to HagiCode and moving components over one by one, the silky-smooth user experience you get in return makes it absolutely worthwhile.

The build system shared in this article is the exact approach we developed through real trial and error while building HagiCode. If you find this approach valuable, that says something about our engineering strength, and HagiCode itself is probably worth a closer look too.

If this article helped you, feel free to give us a Star on GitHub. Public beta has already begun!


Thank you for reading. If you found this article useful, 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.

A Practical Guide to Optimizing Vite Build Performance with Worker Threads

From 120 Seconds to 45 Seconds: A Practical Guide to Optimizing Vite Build Performance with Worker Threads

Section titled “From 120 Seconds to 45 Seconds: A Practical Guide to Optimizing Vite Build Performance with Worker Threads”

When working with large frontend projects, production builds can feel painfully slow. This article shares how we used Node.js Worker Threads to reduce the obfuscation stage in a Vite build from 120 seconds to 45 seconds, along with the implementation details and lessons learned in the HagiCode project.

In our frontend engineering practice, build efficiency issues became increasingly prominent as the project grew. In particular, during the production build process, we usually introduce JavaScript obfuscation tools such as javascript-obfuscator to protect the source code logic. This step is necessary, but it is also computationally expensive and heavily CPU-bound.

During the early development stage of HagiCode, we ran into a very tricky performance bottleneck: production build times deteriorated rapidly as the codebase grew.

The specific pain points were:

  • Obfuscation tasks ran serially on a single thread, maxing out one CPU core while the others sat idle
  • Build time surged from the original 30 seconds to 110-120 seconds
  • The post-change build verification loop became extremely long, seriously slowing development iteration
  • In the CI/CD pipeline, the build stage became the most time-consuming part

Why did HagiCode need this? HagiCode is an AI-driven code assistant whose frontend architecture includes complex business logic and AI interaction modules. To ensure the security of our core code, we enforced high-intensity obfuscation in production releases. Faced with build waits approaching two minutes, we decided to carry out a deep performance optimization of the build system.

Since we have mentioned the project, let me say a bit more about it.

If you have run into frustrations like these during development:

  • Multiple projects and multiple tech stacks, with high maintenance costs for build scripts
  • Complicated CI/CD pipeline configuration, forcing you to check the docs every time you make a change
  • Endless cross-platform compatibility issues
  • Wanting AI to help write code, but finding existing tools not smart enough

Then HagiCode, which we are building, may interest you.

What is HagiCode?

  • An AI-driven code assistant
  • Supports multi-language, cross-platform code generation and optimization
  • Comes with built-in gamification so coding feels less tedious

Why mention it here? The parallel JavaScript obfuscation solution shared in this article is exactly what we refined while building HagiCode. If you find this engineering approach valuable, that suggests our technical taste is probably pretty good, and HagiCode itself may also be worth a look.

Want to learn more?


Analysis: Finding the Breakthrough Point in the Performance Bottleneck

Section titled “Analysis: Finding the Breakthrough Point in the Performance Bottleneck”

Before solving the performance issue, we first needed to clarify our thinking and identify the best technical solution.

There are three main ways to achieve parallel computation in Node.js:

  1. child_process: create independent child processes
  2. Web Workers: mainly used on the browser side
  3. worker_threads: native multithreading support in Node.js

After comparing the options, HagiCode ultimately chose Worker Threads for the following reasons:

  • Zero serialization overhead: Worker Threads run in the same process and can share memory through SharedArrayBuffer or transfer ownership, avoiding the heavy serialization cost of inter-process communication.
  • Native support: built into Node.js 12+ with no need for extra heavyweight dependencies.
  • Unified context: debugging and logging are more convenient than with child processes.

Task Granularity: How Should Obfuscation Tasks Be Split?

Section titled “Task Granularity: How Should Obfuscation Tasks Be Split?”

It is hard to parallelize the obfuscation of one huge JS bundle file because the code has dependencies, but Vite build output is composed of multiple chunks. That gives us a natural parallel boundary:

  • Independence: after Vite packaging, dependencies between different chunks are already decoupled, so they can be processed safely in parallel.
  • Appropriate granularity: projects usually have 10-30 chunks, which is an excellent scale for parallel scheduling.
  • Easy integration: the generateBundle hook in Vite plugins lets us intercept and process these chunks before the files are emitted.

We designed a parallel processing system with four core components:

  1. Task Splitter: iterates over Vite’s bundle object, filters out files that do not need obfuscation such as vendor chunks, and generates a task queue.
  2. Worker Pool Manager: manages the Worker lifecycle and handles task distribution, recycling, and retry on failure.
  3. Progress Reporter: outputs build progress in real time to reduce waiting anxiety.
  4. ObfuscationWorker: the Worker thread that actually performs the obfuscation logic.

Based on the analysis above, we started implementing this parallel obfuscation system.

First, we integrated the parallel obfuscation plugin in vite.config.ts. The configuration is straightforward. You only need to specify the number of Workers and the obfuscation rules.

import { defineConfig } from 'vite'
import { parallelJavascriptObfuscator } from './buildTools/plugin'
export default defineConfig(({ mode }) => {
const isProduction = mode === 'production'
return {
build: {
rollupOptions: {
...(isProduction
? {
plugins: [
parallelJavascriptObfuscator({
enabled: true,
// Automatically adjust based on CPU core count; leave one core for the main thread
workerCount: 4,
retryAttempts: 3,
fallbackToMainThread: true, // Automatically degrade to single-thread mode on failure
// Filter out vendor chunks; third-party libraries usually do not need obfuscation
isVendorChunk: (fileName: string) => fileName.includes('vendor-'),
obfuscationConfig: {
compact: true,
controlFlowFlattening: true,
deadCodeInjection: true,
disableConsoleOutput: true,
// ... more obfuscation options
},
}),
],
}
: {}),
},
},
}
})

A Worker is the unit that executes tasks. We need to define the input and output data structures clearly.

Note: although the code here is simple, there are several pitfalls to watch out for, such as checking whether parentPort is null and handling errors properly. In HagiCode’s implementation, we found that certain special ES6 syntax patterns could cause the obfuscator to crash, so we added try-catch protection.

import { parentPort } from 'worker_threads'
import javascriptObfuscator from 'javascript-obfuscator'
export interface ObfuscationTask {
chunkId: string
code: string
config: any
}
export interface ObfuscationResult {
chunkId: string
obfuscatedCode: string
error?: string
}
// Listen for tasks sent from the main thread
if (parentPort) {
parentPort.on('message', async (task: ObfuscationTask) => {
try {
// Perform obfuscation
const obfuscated = javascriptObfuscator.obfuscate(task.code, task.config)
const result: ObfuscationResult = {
chunkId: task.chunkId,
obfuscatedCode: obfuscated.getObfuscatedCode(),
}
// Send the result back to the main thread
parentPort?.postMessage(result)
} catch (error) {
// Handle exceptions so one Worker crash does not block the whole build
const result: ObfuscationResult = {
chunkId: task.chunkId,
obfuscatedCode: '',
error: error instanceof Error ? error.message : 'Unknown error',
}
parentPort?.postMessage(result)
}
})
}

This is the core of the whole solution. We need to maintain a fixed-size Worker pool and schedule tasks using a FIFO (first in, first out) strategy.

import { Worker } from 'worker_threads'
import os from 'os'
export class WorkerPool {
private workers: Worker[] = []
private taskQueue: Array<{
task: ObfuscationTask
resolve: (result: ObfuscationResult) => void
reject: (error: Error) => void
}> = []
constructor(options: WorkerPoolOptions = {}) {
// Default to core count - 1 so the main thread still has some breathing room
const workerCount = options.workerCount ?? Math.max(1, (os.cpus().length || 4) - 1)
for (let i = 0; i < workerCount; i++) {
this.createWorker()
}
}
private createWorker() {
const worker = new Worker('./worker.ts')
worker.on('message', (result) => {
// After one task finishes, take the next task from the queue
const nextTask = this.taskQueue.shift()
if (nextTask) {
this.dispatchTask(worker, nextTask)
} else {
// If there are no pending tasks, mark the Worker as idle
this.activeWorkers.delete(worker)
}
})
this.workers.push(worker)
}
// Submit a task to the pool
public runTask(task: ObfuscationTask): Promise<ObfuscationResult> {
return new Promise((resolve, reject) => {
const job = { task, resolve, reject }
const idleWorker = this.workers.find(w => !this.activeWorkers.has(w))
if (idleWorker) {
this.dispatchTask(idleWorker, job)
} else {
this.taskQueue.push(job)
}
})
}
private dispatchTask(worker: Worker, job: any) {
this.activeWorkers.set(worker, job.task)
worker.postMessage(job.task)
}
}

Waiting is painful, especially when you have no idea how much longer it will take. So we added a simple progress reporter to provide real-time feedback on the current status.

export class ProgressReporter {
private completed = 0
private readonly total: number
private readonly startTime: number
constructor(total: number) {
this.total = total
this.startTime = Date.now()
}
increment(): void {
this.completed++
this.report()
}
private report(): void {
const now = Date.now()
const elapsed = now - this.startTime
const percentage = (this.completed / this.total) * 100
// Simple ETA estimate
const avgTimePerChunk = elapsed / this.completed
const remaining = (this.total - this.completed) * avgTimePerChunk
console.log(
`[Parallel Obfuscation] ${this.completed}/${this.total} chunks completed (${percentage.toFixed(1)}%) | ETA: ${(remaining / 1000).toFixed(1)}s`
)
}
}

After deploying this solution, the build performance of the HagiCode project improved immediately.

We tested in the following environment:

  • CPU: Intel Core i7-12700K (12 cores / 20 threads)
  • RAM: 32GB DDR4
  • Node.js: v18.17.0
  • OS: Ubuntu 22.04

Results comparison:

  • Single-threaded (before optimization): 118 seconds
  • 4 Workers: 55 seconds (53% improvement)
  • 8 Workers: 48 seconds (60% improvement)
  • 12 Workers: 45 seconds (62% improvement)

As you can see, the gains were not linear. Once the Worker count exceeded 8, the improvement became smaller. This was mainly limited by the evenness of task distribution and memory bandwidth bottlenecks.

In HagiCode’s real-world use, we also ran into several pitfalls, so here they are for reference:

Q1: Build time did not decrease much and even became slower?

  • Reason: creating Workers has its own overhead, or too many Workers were configured, causing frequent context switching.
  • Solution: we recommend setting the Worker count to CPU core count - 1. Also check whether any single chunk is especially large, for example > 5MB. That kind of “monster” file will become the bottleneck, so you may need to optimize your code splitting strategy.

Q2: Workers occasionally crash and cause build failures?

  • Reason: some special code syntax patterns may cause internal errors inside the obfuscator.
  • Solution: we implemented an automatic degradation mechanism. When a Worker reaches the failure threshold, the plugin automatically falls back to single-thread mode to ensure the build does not stop. At the same time, it records the filename that caused the error so it can be fixed later.

Q3: Memory usage is too high (OOM)?

  • Reason: each Worker needs its own memory space to load the obfuscator and parse the AST.
  • Solution:
    • Reduce the number of Workers.
    • Increase the Node.js memory limit: NODE_OPTIONS="--max-old-space-size=4096" npm run build.
    • Make sure Workers do not keep unnecessary references to large objects.

By introducing Node.js Worker Threads, we successfully reduced the production build time of the HagiCode project from 120 seconds to around 45 seconds, greatly improving the development experience and CI/CD efficiency.

The core of this solution is:

  1. Split tasks properly: use Vite chunks as the parallel unit.
  2. Control resources: use a Worker pool to avoid resource exhaustion.
  3. Design for fault tolerance: an automatic degradation mechanism ensures build stability.

If you are also struggling with frontend build efficiency, or your project also does heavy code processing, this solution is worth trying. Of course, we would recommend taking a direct look at HagiCode, where these engineering details are already integrated.

If this article helped you, feel free to give us a Star on GitHub or join the public beta and try it out.


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.

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.

HagiCode in Practice: How to Use GitHub Actions for Docusaurus Automated Deployment

Adding GitHub Pages Automated Deployment Support to HagiCode

Section titled “Adding GitHub Pages Automated Deployment Support to HagiCode”

The project’s early codename was PCode, and it has now officially been renamed HagiCode. This article records how we introduced automated static site deployment for the project so publishing content becomes as easy as drinking water.

During HagiCode development, we ran into a very practical problem: as the amount of documentation and proposals kept growing, efficiently managing and presenting that content became increasingly urgent. We decided to use GitHub Pages to host our static site, but building and deploying manually was simply too much trouble. Every change required a local build, packaging, and then a manual push to the gh-pages branch. That was not only inefficient, but also error-prone.

To solve this problem (mostly because we wanted to be lazy), we needed an automated deployment workflow. This article documents in detail how we added GitHub Actions-based automated deployment support to the HagiCode project, so we can focus on creating content and leave the rest to automation.

Hey, let us introduce what we are building

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

Smarter - AI assistance throughout the whole process, from ideas to code, multiplying coding efficiency. More convenient - multi-threaded concurrent operations that make full use of resources and keep the development workflow smooth. More enjoyable - gamification mechanisms and an achievement system that make coding less dull 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 it out on GitHub~

Before getting started, we first need to clarify what exactly this task is supposed to accomplish. After all, sharpening the axe does not delay the work.

  1. Automated build: Automatically trigger the build process when code is pushed to the main branch.
  2. Automated deployment: After a successful build, automatically deploy the generated static files to GitHub Pages.
  3. Environment consistency: Ensure the CI environment matches the local build environment to avoid the awkward “it works locally but fails in production” situation.

Since HagiCode is built on Docusaurus (a very popular React static site generator), we can use GitHub Actions to achieve this goal.

GitHub Actions is the CI/CD service provided by GitHub. By defining workflow files in YAML format inside the repository, we can customize a variety of automation tasks.

We need to create a new configuration file in the .github/workflows folder under the project root, for example deploy.yml. If the folder does not exist, remember to create it manually first.

The core logic of this configuration file is as follows:

  1. Trigger condition: Listen for push events on the main branch.
  2. Runtime environment: The latest Ubuntu.
  3. Build steps:
    • Check out the code
    • Install Node.js
    • Install dependencies (npm install)
    • Build the static files (npm run build)
  4. Deployment step: Use the official action-gh-pages to push the build artifacts to the gh-pages branch.

Below is the configuration template we ultimately adopted:

name: Deploy to GitHub Pages
# Trigger condition: when pushing to the main branch
on:
push:
branches:
- main
# You can add path filters as needed, for example only build when docs change
# paths:
# - 'docs/**'
# - 'package.json'
# Set permissions, which are important for deploying to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Concurrency control: cancel older builds on the same branch
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Note: you must set fetch-depth: 0, otherwise the build version may be inaccurate
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20 # Recommended to match your local development environment
cache: 'npm' # Enabling cache can speed up the build process
- name: Install dependencies
run: npm ci
# Use npm ci instead of npm install because it is faster, stricter, and better suited for CI
- name: Build website
run: npm run build
env:
# If your site build requires environment variables, configure them here
# NODE_ENV: production
# PUBLIC_URL: /your-repo-name
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: ./build # Default Docusaurus output directory
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

Pitfalls Encountered During Implementation

Section titled “Pitfalls Encountered During Implementation”

In practice, we ran into a few issues. I am sharing them here in the hope that everyone can avoid them, or at least prepare solutions in advance.

When we first set things up, deployment kept failing with a 403 (Forbidden) error. After a long investigation, we discovered that GitHub’s default GITHUB_TOKEN did not have permission to write to Pages.

Solution: In the repository Settings -> Actions -> General -> Workflow permissions, make sure to choose “Read and write permissions”.

By default, Docusaurus puts the built static files in the build directory. However, some projects may use different configurations. For example, Create React App defaults to build, while Vite defaults to dist. If Actions reports that it cannot find files, remember to check the output path configuration in docusaurus.config.js.

If your repository is not a user homepage (that is, not username.github.io) but instead a project page (such as username.github.io/project-name), you need to configure baseUrl.

In docusaurus.config.js:

module.exports = {
// ...
url: 'https://hagicode.com', // Your Hagicode URL
baseUrl: '/', // Deploy at the root path
// ...
};

This detail is easy to overlook. If it is configured incorrectly, the page may load as a blank screen because the resource paths cannot be resolved.

After configuring everything and pushing the code, we can head to the Actions tab in the GitHub repository and enjoy the show.

You will see a yellow circle while the workflow is running. When it turns green, it means success. If it turns red, click into the logs to inspect the issue. Usually, you can track it down there, and most of the time it is a typo or an incorrect path configuration.

Once the build succeeds, visit https://<your-username>.github.io/<repo-name>/ and you should see your brand-new site.

By introducing GitHub Actions, we successfully implemented automated deployment for the HagiCode documentation site. This not only saves the time previously spent on manual operations, but more importantly standardizes the release process. Now, no matter which teammate updates the documentation, as long as the changes are merged into the main branch, the latest content will appear online a few minutes later.

Key benefits:

  • Higher efficiency: from “manual packaging and manual upload” to “code is the release”.
  • Fewer errors: removes the possibility of human operational mistakes.
  • Better experience: lets developers focus more on content quality instead of being distracted by tedious deployment steps.

Although setting up CI/CD can be a bit troublesome at first, especially with all the permissions and path issues, it is a one-time investment with huge long-term returns. I strongly recommend that every static site project adopt a similar automated workflow.


Thank you for reading. If you found this article useful, 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.

How to Use GitHub Actions + image-syncer for Automated Image Sync from Docker Hub to Azure ACR

Automating Image Sync from Docker Hub to Azure ACR

Section titled “Automating Image Sync from Docker Hub to Azure ACR”

This article explains how to use GitHub Actions and the image-syncer tool to automate image synchronization from Docker Hub to Azure Container Registry, solving the problem of slow Docker Hub access in mainland China and some Azure regions, while improving image availability and deployment efficiency in Azure environments.

The HagiCode project uses Docker images as its core runtime components, with the main images hosted on Docker Hub. As the project has evolved and Azure deployment needs have grown, we encountered the following pain points:

  • Slow image pulls, because access to Docker Hub is limited in mainland China and some Azure regions
  • Relying on a single image source creates a single point of failure risk
  • Using Azure Container Registry in Azure environments provides better network performance and integration experience

To solve these problems, we need to establish an automated image synchronization mechanism that regularly syncs images from Docker Hub to Azure ACR, ensuring users get faster image pull speeds and higher availability in Azure environments.

We are building HagiCode, an AI-driven coding assistant that makes development smarter, more convenient, and more enjoyable.

Smart: AI assistance throughout the entire process, from idea to code, boosting coding efficiency several times over. Convenient: Multi-threaded concurrent operations make full use of resources and keep the development workflow smooth. Fun: Gamification and an achievement system make coding less dull and more rewarding.

The project is evolving rapidly. If you are interested in technical writing, knowledge management, or AI-assisted development, welcome to check it out on GitHub.

When defining the solution, we compared multiple technical approaches:

  • Incremental sync: only synchronizes changed image layers, significantly reducing network transfer
  • Resume support: synchronization can resume after network interruptions
  • Concurrency control: supports configurable concurrency to improve large image sync efficiency
  • Robust error handling: built-in retry mechanism for failures (3 times by default)
  • Lightweight deployment: single binary with no dependencies
  • Multi-registry support: compatible with Docker Hub, Azure ACR, Harbor, and more
  • No incremental sync support: each run requires pulling the full image content
  • Lower efficiency: large network transfer volume and longer execution time
  • Simple and easy to use: relies on familiar docker pull / docker push commands
  • Higher complexity: requires Azure CLI authentication setup
  • Functional limitations: az acr import is relatively limited
  • Native integration: integrates well with Azure services

Decision 1: Set the sync frequency to daily at 00:00 UTC

Section titled “Decision 1: Set the sync frequency to daily at 00:00 UTC”
  • Balances image freshness with resource consumption
  • Avoids peak business hours and reduces impact on other operations
  • Docker Hub images are usually updated after daily builds
  • Maintains full consistency with Docker Hub
  • Provides flexible version choices for users
  • Simplifies sync logic by avoiding complex tag filtering rules

Decision 3: Store credentials in GitHub Secrets

Section titled “Decision 3: Store credentials in GitHub Secrets”
  • Natively supported by GitHub Actions with strong security
  • Simple to configure and easy to manage and maintain
  • Supports repository-level access control
  • Use GitHub Secrets for encrypted storage
  • Rotate ACR passwords regularly
  • Limit ACR account permissions to push-only
  • Monitor ACR access logs

Risk 2: Sync failures causing image inconsistency

Section titled “Risk 2: Sync failures causing image inconsistency”
  • image-syncer includes a built-in incremental sync mechanism
  • Automatic retry on failure (3 times by default)
  • Detailed error logs and failure notifications
  • Resume support
  • Incremental sync reduces network transfer
  • Configurable concurrency (10 in the current setup)
  • Monitor the number and size of synchronized images
  • Run synchronization during off-peak hours

We use an automated GitHub Actions + image-syncer solution to synchronize images from Docker Hub to Azure ACR.

  • Create or confirm an Azure Container Registry in Azure Portal
  • Create ACR access credentials (username and password)
  • Confirm access permissions for the Docker Hub image repository

Add the following secrets in the GitHub repository settings:

  • AZURE_ACR_USERNAME: Azure ACR username
  • AZURE_ACR_PASSWORD: Azure ACR password

Configure the workflow in .github/workflows/sync-docker-acr.yml:

  • Scheduled trigger: every day at 00:00 UTC
  • Manual trigger: supports workflow_dispatch
  • Extra trigger: run when the publish branch receives a push (for fast synchronization)
SequenceParticipantActionDescription
1GitHub ActionsTrigger workflowTriggered by schedule, manual run, or a push to the publish branch
2GitHub Actions → image-syncerDownload and run the sync toolEnter the actual sync phase
3image-syncer → Docker HubFetch image manifests and tag listRead source repository metadata
4image-syncer → Azure ACRFetch existing image information from the target repositoryDetermine the current target-side state
5image-syncerCompare source and target differencesIdentify image layers that need to be synchronized
6image-syncer → Docker HubPull changed image layersTransfer only the content that needs updating
7image-syncer → Azure ACRPush changed image layersComplete incremental synchronization
8image-syncer → GitHub ActionsReturn synchronization statisticsIncludes results, differences, and error information
9GitHub ActionsRecord logs and upload artifactsUseful for follow-up auditing and troubleshooting

Here is the actual workflow configuration in use (.github/workflows/sync-docker-acr.yml):

name: Sync Docker Image to Azure ACR
on:
schedule:
- cron: "0 0 * * *" # Every day at 00:00 UTC
workflow_dispatch: # Manual trigger
push:
branches: [publish]
permissions:
contents: read
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download image-syncer
run: |
# Download the image-syncer binary
wget https://github.com/AliyunContainerService/image-syncer/releases/download/v1.5.5/image-syncer-v1.5.5-linux-amd64.tar.gz
tar -zxvf image-syncer-v1.5.5-linux-amd64.tar.gz
chmod +x image-syncer
- name: Create auth config
run: |
# Generate the authentication configuration file (YAML format)
cat > auth.yaml <<EOF
hagicode.azurecr.io:
username: "${{ secrets.AZURE_ACR_USERNAME }}"
password: "${{ secrets.AZURE_ACR_PASSWORD }}"
EOF
- name: Create images config
run: |
# Generate the image synchronization configuration file (YAML format)
cat > images.yaml <<EOF
docker.io/newbe36524/hagicode: hagicode.azurecr.io/hagicode
EOF
- name: Run image-syncer
run: |
# Run synchronization (using the newer --auth and --images parameters)
./image-syncer --auth=./auth.yaml --images=./images.yaml --proc=10 --retries=3
- name: Upload logs
if: always()
uses: actions/upload-artifact@v4
with:
name: sync-logs
path: image-syncer-*.log
retention-days: 7
  • Scheduled trigger: cron: "0 0 * * *" - runs every day at 00:00 UTC
  • Manual trigger: workflow_dispatch - allows users to run it manually in the GitHub UI
  • Push trigger: push: branches: [publish] - triggered when the publish branch receives a push (for fast synchronization)

2. Authentication configuration (auth.yaml)

Section titled “2. Authentication configuration (auth.yaml)”
hagicode.azurecr.io:
username: "${{ secrets.AZURE_ACR_USERNAME }}"
password: "${{ secrets.AZURE_ACR_PASSWORD }}"
docker.io/newbe36524/hagicode: hagicode.azurecr.io/hagicode

This configuration means synchronizing all tags from docker.io/newbe36524/hagicode to hagicode.azurecr.io/hagicode

  • --auth=./auth.yaml: path to the authentication configuration file
  • --images=./images.yaml: path to the image synchronization configuration file
  • --proc=10: set concurrency to 10
  • --retries=3: retry failures 3 times

Configure the following in Settings → Secrets and variables → Actions in the GitHub repository:

Secret NameDescriptionExample ValueHow to Get It
AZURE_ACR_USERNAMEAzure ACR usernamehagicodeAzure Portal → ACR → Access keys
AZURE_ACR_PASSWORDAzure ACR passwordxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxAzure Portal → ACR → Access keys → Password
  1. Open the Actions tab of the GitHub repository
  2. Select the Sync Docker Image to Azure ACR workflow
  3. Click the Run workflow button
  4. Choose the branch and click Run workflow to confirm
  1. Click a specific workflow run record on the Actions page
  2. View the execution logs for each step
  3. Download the sync-logs file from the Artifacts section at the bottom of the page
Terminal window
# Log in to Azure ACR
az acr login --name hagicode
# List images and their tags
az acr repository show-tags --name hagicode --repository hagicode --output table
  • Rotate Azure ACR passwords regularly (recommended every 90 days)
  • Use a dedicated ACR service account with push-only permissions
  • Monitor ACR access logs to detect abnormal access in time
  • Do not output credentials in logs
  • Do not commit credentials to the code repository
  • Adjust the --proc parameter: tune concurrency based on network bandwidth (recommended 5-20)
  • Monitor synchronization time: if it takes too long, consider reducing concurrency
  • Clean up logs regularly: set a reasonable retention-days value (7 days in the current setup)
Error: failed to authenticate to hagicode.azurecr.io

Solution:

  1. Check whether GitHub Secrets are configured correctly
  2. Verify whether the Azure ACR password has expired
  3. Confirm whether the ACR service account permissions are correct
Error: timeout waiting for response

Solution:

  1. Check network connectivity
  2. Reduce concurrency (--proc parameter)
  3. Wait for the network to recover and trigger the workflow again
Warning: some tags failed to sync

Solution:

  1. Check the synchronization logs to identify failed tags
  2. Manually trigger the workflow to synchronize again
  3. Verify that the source image on Docker Hub is working properly
  • Regularly check the Actions page to confirm workflow run status
  • Configure GitHub notifications to receive workflow failure alerts promptly
  • Monitor Azure ACR storage usage
  • Regularly verify tag consistency

Q1: How do I sync specific tags instead of all tags?

Section titled “Q1: How do I sync specific tags instead of all tags?”

Modify the images.yaml configuration file:

# Sync only the latest and v1.0 tags
docker.io/newbe36524/hagicode:latest: hagicode.azurecr.io/hagicode:latest
docker.io/newbe36524/hagicode:v1.0: hagicode.azurecr.io/hagicode:v1.0

Q2: How do I sync multiple image repositories?

Section titled “Q2: How do I sync multiple image repositories?”

Add multiple lines in images.yaml:

docker.io/newbe36524/hagicode: hagicode.azurecr.io/hagicode
docker.io/newbe36524/another-image: hagicode.azurecr.io/another-image

Q3: How do I retry after a synchronization failure?

Section titled “Q3: How do I retry after a synchronization failure?”
  • Automatic retry: image-syncer includes a built-in retry mechanism (3 times by default)
  • Manual retry: click Re-run all jobs on the GitHub Actions page

Q4: How do I view detailed synchronization progress?

Section titled “Q4: How do I view detailed synchronization progress?”
  • View real-time logs on the Actions page
  • Download the sync-logs artifact to see the full log file
  • The log file includes the synchronization status and transfer speed for each tag
  • Initial full synchronization: typically takes 10-30 minutes depending on image size
  • Incremental synchronization: usually 2-5 minutes if image changes are small
  • Time depends on network bandwidth, image size, and concurrency settings

Add a notification step to the workflow:

- name: Notify on success
if: success()
run: |
echo "Docker images synced successfully to Azure ACR"

Add tag filtering logic to the workflow:

- name: Filter tags
run: |
# Sync only tags that start with v
echo "docker.io/newbe36524/hagicode:v* : hagicode.azurecr.io/hagicode:v*" > images.yaml

3. Add a synchronization statistics report

Section titled “3. Add a synchronization statistics report”
- name: Generate report
if: always()
run: |
echo "## Sync Report" >> $GITHUB_STEP_SUMMARY
echo "- Total tags: $(grep -c 'synced' image-syncer-*.log)" >> $GITHUB_STEP_SUMMARY
echo "- Sync time: ${{ steps.sync.outputs.duration }}" >> $GITHUB_STEP_SUMMARY

With the method introduced in this article, we successfully implemented automated image synchronization from Docker Hub to Azure ACR. This solution uses the scheduled and manual trigger capabilities of GitHub Actions together with the incremental synchronization and error-handling features of image-syncer to ensure timely and consistent image synchronization.

We also discussed security best practices, performance optimization, troubleshooting, and other related topics to help users better manage and maintain this synchronization mechanism. We hope this article provides valuable reference material for developers who need to deploy Docker images in Azure environments.


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.

GitHub Issues Integration

Building a GitHub Issues Integration from Scratch: HagiCode’s Frontend Direct Connection Practice

Section titled “Building a GitHub Issues Integration from Scratch: HagiCode’s Frontend Direct Connection Practice”

This article documents the full process of integrating GitHub Issues into the HagiCode platform. We will explore how to use a “frontend direct connection + minimal backend” architecture to achieve secure OAuth authentication and efficient issue synchronization while keeping the backend lightweight.

As an AI-assisted development platform, HagiCode’s core value lies in connecting ideas with implementation. But in actual use, we found that after users complete a Proposal in HagiCode, they often need to manually copy the content into GitHub Issues for project tracking.

This creates several obvious pain points:

  1. Fragmented workflow: Users need to switch back and forth between two systems. The experience is not smooth, and key information can easily be lost during copy and paste.
  2. Inconvenient collaboration: Other team members are used to checking tasks on GitHub and cannot directly see proposal progress inside HagiCode.
  3. Repeated manual work: Every time a proposal is updated, someone has to manually update the corresponding issue on GitHub, adding unnecessary maintenance cost.

To solve this problem, we decided to introduce the GitHub Issues Integration feature, connecting HagiCode sessions with GitHub repositories to enable “one-click sync.”

Hey, let us introduce what we are building

We are building HagiCode — an AI-powered coding assistant that makes development smarter, easier, and more enjoyable.

Smarter — AI assists throughout the entire journey, from idea to code, multiplying development efficiency. Easier — Multi-threaded concurrent operations make full use of resources and keep the development workflow smooth. More enjoyable — Gamification and an achievement system make coding less tedious and more rewarding.

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


Technical Choice: Frontend Direct Connection vs Backend Proxy

Section titled “Technical Choice: Frontend Direct Connection vs Backend Proxy”

When designing the integration approach, we had two options in front of us: the traditional “backend proxy model” and the more aggressive “frontend direct connection model.”

In the traditional backend proxy model, every request from the frontend must first go through our backend, which then calls the GitHub API. This centralizes the logic, but it also puts a significant burden on the backend:

  1. Bloated backend: We would need to write a dedicated GitHub API client wrapper and also handle the complex OAuth state machine.
  2. Token risk: The user’s GitHub token would have to be stored in the backend database. Even with encryption, this still increases the security surface.
  3. Development cost: We would need database migrations to store tokens and an additional synchronization service to maintain.

The frontend direct connection model is much lighter. In this approach, we use the backend only for the most sensitive “secret exchange” step (the OAuth callback). After obtaining the token, we store it directly in the browser’s localStorage. Later operations such as creating issues and updating comments are sent directly from the frontend to GitHub over HTTP.

Comparison DimensionBackend Proxy ModelFrontend Direct Connection Model
Backend complexityRequires a full OAuth service and GitHub API clientOnly needs an OAuth callback endpoint
Token managementMust be encrypted and stored in the database, with leakage riskStored in the browser and visible only to the user
Implementation costRequires database migrations and multi-service developmentPrimarily frontend work
User experienceCentralized logic, but server latency may be slightly higherExtremely fast response with direct GitHub interaction

Because we wanted rapid integration and minimal backend changes, we ultimately chose the “frontend direct connection model”. It is like giving the browser a “temporary pass.” Once it gets the pass, the browser can go handle things on GitHub by itself without asking the backend administrator for approval every time.


After settling on the architecture, we needed to design the specific data flow. The core of the synchronization process is how to obtain the token securely and use it efficiently.

The whole system can be abstracted into three roles: the browser (frontend), the HagiCode backend, and GitHub.

+--------------+ +--------------+ +--------------+
| Frontend | | Backend | | GitHub |
| React | | ASP.NET | | REST API |
| | | | | |
| +--------+ | | | | |
| | OAuth |--+--------> /callback | | |
| | Flow | | | | | |
| +--------+ | | | | |
| | | | | |
| +--------+ | | +--------+ | | +--------+ |
| | GitHub | +------------> Session | +----------> Issues | |
| | API | | | |Metadata| | | | | |
| | Direct | | | +--------+ | | +--------+ |
| +--------+ | | | | |
+--------------+ +--------------+ +--------------+

The key point is: only one small step in OAuth (exchanging the code for a token) needs to go through the backend. After that, the heavy lifting (creating issues) is handled directly between the frontend and GitHub.

When the user clicks the “Sync to GitHub” button in the HagiCode UI, a series of complex actions takes place:

User clicks "Sync to GitHub"
1. Frontend checks localStorage for the GitHub token
2. Format issue content (convert the Proposal into Markdown)
3. Frontend directly calls the GitHub API to create/update the issue
4. Call the HagiCode backend API to update Session.metadata (store issue URL and other info)
5. Backend broadcasts the SessionUpdated event via SignalR
6. Frontend receives the event and updates the UI to show the "Synced" state

Security is always the top priority when integrating third-party services. We made the following considerations:

  1. Defend against CSRF attacks: Generate a random state parameter during the OAuth redirect and store it in sessionStorage. Strictly validate the state in the callback to prevent forged requests.
  2. Isolated token storage: The token is stored only in the browser’s localStorage. Using the Same-Origin Policy, only HagiCode scripts can read it, avoiding the risk of a server-side database leak affecting users.
  3. Error boundaries: We designed dedicated handling for common GitHub API errors (such as 401 expired token, 422 validation failure, and 429 rate limiting), so users receive friendly feedback.

In Practice: Implementation Details in Code

Section titled “In Practice: Implementation Details in Code”

Theory only goes so far. Let us look at how the code actually works.

The backend only needs to do two things: store synchronization information and handle the OAuth callback.

Database changes We only need to add a Metadata column to the Sessions table to store extension data in JSON format.

-- Add metadata column to Sessions table
ALTER TABLE "Sessions" ADD COLUMN "Metadata" text NULL;

Entity and DTO definitions

src/HagiCode.DomainServices.Contracts/Entities/Session.cs
public class Session : AuditedAggregateRoot<SessionId>
{
// ... other properties ...
/// <summary>
/// JSON metadata for storing extension data like GitHub integration
/// </summary>
public string? Metadata { get; set; }
}
// DTO definition for easier frontend serialization
public class GitHubIssueMetadata
{
public required string Owner { get; set; }
public required string Repo { get; set; }
public int IssueNumber { get; set; }
public required string IssueUrl { get; set; }
public DateTime SyncedAt { get; set; }
public string LastSyncStatus { get; set; } = "success";
}
public class SessionMetadata
{
public GitHubIssueMetadata? GitHubIssue { get; set; }
}

This is the entry point of the connection. We use the standard Authorization Code Flow.

src/HagiCode.Client/src/services/githubOAuth.ts
// Generate the authorization URL and redirect
export async function generateAuthUrl(): Promise<string> {
const state = generateRandomString(); // Generate a random string for CSRF protection
sessionStorage.setItem('hagicode_github_state', state);
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: window.location.origin + '/settings?tab=github&oauth=callback',
scope: ['repo', 'public_repo'].join(' '),
state: state,
});
return `https://github.com/login/oauth/authorize?${params.toString()}`;
}
// Handle the code-to-token exchange on the callback page
export async function exchangeCodeForToken(code: string, state: string): Promise<GitHubToken> {
// 1. Validate state to prevent CSRF
const savedState = sessionStorage.getItem('hagicode_github_state');
if (state !== savedState) throw new Error('Invalid state parameter');
// 2. Call the backend API to exchange the token
// Note: this must go through the backend because ClientSecret cannot be exposed to the frontend
const response = await fetch('/api/GitHubOAuth/callback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, state, redirectUri: window.location.origin + '/settings?tab=github&oauth=callback' }),
});
if (!response.ok) throw new Error('Failed to exchange token');
const token = await response.json();
// 3. Save into LocalStorage
saveToken(token);
return token;
}

Once we have the token, we need a solid tool for calling the GitHub API.

src/HagiCode.Client/src/services/githubApiClient.ts
const GITHUB_API_BASE = 'https://api.github.com';
// Core request wrapper
async function githubApi<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = localStorage.getItem('gh_token');
if (!token) throw new Error('Not connected to GitHub');
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json', // Specify the API version
},
});
// Error handling logic
if (!response.ok) {
if (response.status === 401) throw new Error('GitHub token expired, please reconnect');
if (response.status === 403) throw new Error('No permission to access this repository or rate limit exceeded');
if (response.status === 422) throw new Error('Issue validation failed, the title may be duplicated');
throw new Error(`GitHub API Error: ${response.statusText}`);
}
return response.json();
}
// Create issue
export async function createIssue(owner: string, repo: string, data: { title: string, body: string, labels: string[] }) {
return githubApi(`/repos/${owner}/${repo}/issues`, {
method: 'POST',
body: JSON.stringify(data),
});
}

The final step is to convert HagiCode session data into the format of a GitHub issue. It is a bit like translation work.

// Convert a Session object into a Markdown string
function formatIssueForSession(session: Session): string {
let content = `# ${session.title}\n\n`;
content += `**> HagiCode Session:** #${session.code}\n`;
content += `**> Status:** ${session.status}\n\n`;
content += `## Description\n\n${session.description || 'No description provided.'}\n\n`;
// If this is a Proposal session, add extra fields
if (session.type === 'proposal') {
content += `## Chief Complaint\n\n${session.chiefComplaint || ''}\n\n`;
// Add a deep link so users can jump back from GitHub to HagiCode
content += `---\n\n**[View in HagiCode](hagicode://sessions/${session.id})**\n`;
}
return content;
}
// Main logic when clicking the sync button
const handleSync = async (session: Session) => {
try {
const repoInfo = parseRepositoryFromUrl(session.repoUrl); // Parse the repository URL
if (!repoInfo) throw new Error('Invalid repository URL');
toast.loading('Syncing to GitHub...');
// 1. Format content
const issueBody = formatIssueForSession(session);
// 2. Call API
const issue = await githubApiClient.createIssue(repoInfo.owner, repoInfo.repo, {
title: `[HagiCode] ${session.title}`,
body: issueBody,
labels: ['hagicode', 'proposal', `status:${session.status}`],
});
// 3. Update Session Metadata (save the issue link)
await SessionsService.patchApiSessionsSessionId(session.id, {
metadata: {
githubIssue: {
owner: repoInfo.owner,
repo: repoInfo.repo,
issueNumber: issue.number,
issueUrl: issue.html_url,
syncedAt: new Date().toISOString(),
}
}
});
toast.success('Synced successfully!');
} catch (err) {
console.error(err);
toast.error('Sync failed, please check your token or network');
}
};

With this “frontend direct connection” approach, we achieved seamless GitHub Issues integration with the least possible backend code.

  1. High development efficiency: Backend changes are minimal, mainly one extra database field and a simple OAuth callback endpoint. Most logic is completed on the frontend.
  2. Strong security: The token does not pass through the server database, reducing leakage risk.
  3. Great user experience: Requests are initiated directly from the frontend, so response speed is fast and there is no need for backend forwarding.

There are a few pitfalls to keep in mind during real deployment:

  • OAuth App settings: Remember to enter the correct Authorization callback URL in your GitHub OAuth App settings (usually http://localhost:3000/settings?tab=github&oauth=callback).
  • Rate limits: GitHub API limits unauthenticated requests quite strictly, but with a token the quota is usually sufficient (5000 requests/hour).
  • URL parsing: Users enter all kinds of repository URLs, so make sure your regex can match .git suffixes, SSH formats, and similar cases.

The current feature is still one-way synchronization (HagiCode -> GitHub). In the future, we plan to implement two-way synchronization through GitHub Webhooks. For example, if an issue is closed on GitHub, the session state on the HagiCode side could also update automatically. That will require us to expose a webhook endpoint on the backend, which will be an interesting next step.

We hope this article gives you a bit of inspiration for your own third-party integration development. If you have questions, feel free to open an issue for discussion on HagiCode GitHub.