Skip to content

Blog

How to Install and Use Hermes: A Quick Start from the Local CLI to Feishu Integration

If you want to install Hermes and start using it, the shortest path is really just three steps:

  1. Run the official installation command
  2. Start the CLI in your terminal with hermes
  3. If you want to keep using it in Feishu, then configure hermes gateway setup

This article does not try to explain every Hermes capability all at once. Instead, it helps you complete the most important beginner loop first: install it, get it running, start using it, and then connect it to one of the most common messaging-platform scenarios.

Hermes Agent is an AI agent that you can use either from a local terminal or through a messaging-platform gateway.

For most developers, it has two common entry points:

  • CLI: Type hermes in your terminal to enter the interactive interface directly.
  • Messaging Gateway: Run hermes gateway, then chat with it from platforms such as Feishu, Telegram, Discord, and Slack.

If your goal right now is simply to get started quickly, do not reverse the order. Start with this path instead:

  • Install Hermes first
  • Verify it works from the CLI first
  • Then decide whether you want to connect a messaging platform

This makes problems easier to diagnose and is more suitable for people using Hermes for the first time.

According to the Hermes README, the official quick-install path supports these environments:

  • Linux
  • macOS
  • WSL2
  • Android via Termux

Hermes does not currently support running directly on native Windows. If you are using Windows, the recommended approach is to install WSL2 first and then run the installation command inside WSL2.

It is best to make this clear at the beginning, because many installation failures are not caused by the command itself, but by using an unsupported runtime environment.

The quick installation command provided in the Hermes README is:

Terminal window
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash

This command runs the official installation script and handles platform-specific initialization steps.

Once installation finishes, reload your shell environment first. The most common command is:

Terminal window
source ~/.bashrc

If you use zsh, you can use:

Terminal window
source ~/.zshrc

How to confirm Hermes is installed correctly

Section titled “How to confirm Hermes is installed correctly”

The most direct way to check is to run:

Terminal window
hermes

If you want additional confirmation that your configuration and dependencies are working, you can also run:

Terminal window
hermes doctor

hermes doctor is especially useful in these situations:

  • The command behaves abnormally after installation
  • Model configuration fails
  • The gateway fails to start
  • You are not sure whether your environment dependencies are complete

How to start using Hermes for the first time

Section titled “How to start using Hermes for the first time”

If you just want to confirm as quickly as possible that Hermes works, the simplest method is:

Terminal window
hermes

This launches the interactive Hermes CLI. For first-time Hermes users, it is also the most recommended starting point, because you can verify the most essential things first:

  • Whether the command is actually available
  • Whether the current model configuration works properly
  • Whether the terminal toolchain is working correctly
  • Whether the interaction style matches what you need

These commands are enough for your first round of setup

Section titled “These commands are enough for your first round of setup”

The Hermes README lists several high-frequency commands, and together they form a practical first-use path:

Terminal window
hermes model
hermes tools
hermes config set
hermes setup
hermes update
hermes doctor

If you are not sure what each one does, remember them like this:

  • hermes model: choose or switch models
  • hermes tools: view and configure currently available tools
  • hermes config set: change specific configuration items
  • hermes setup: run the full initialization wizard once
  • hermes update: update Hermes
  • hermes doctor: troubleshoot problems

For beginners, the most practical order is usually:

  1. Run hermes model first
  2. If you want to configure all common options at once, then run hermes setup

1. Use Hermes in the terminal as a daily development assistant

Section titled “1. Use Hermes in the terminal as a daily development assistant”

CLI mode is a good fit for these scenarios:

  • Ask questions directly while writing code locally
  • Inspect projects, edit files, and run commands
  • Do one-off debugging or review work
  • Collaborate continuously in the current working directory

Its biggest advantage is that it is the shortest path: no extra platform integration, no bot configuration to handle up front, and it is the best way to build your first set of usage habits.

2. Use Hermes through a messaging platform

Section titled “2. Use Hermes through a messaging platform”

If you want to chat with Hermes on platforms such as Feishu, Telegram, or Discord, you need to use the messaging gateway.

The most common entry commands are:

Terminal window
hermes gateway setup
hermes gateway

Specifically:

  • hermes gateway setup is used for interactive platform configuration
  • hermes gateway is used to start the gateway process

According to the official documentation, the gateway is a unified background process that connects your configured platforms, manages sessions, and handles features such as cron jobs.

Using Feishu as an example: how to connect Hermes to a messaging platform

Section titled “Using Feishu as an example: how to connect Hermes to a messaging platform”

If most of your daily work happens in Feishu, then Feishu/Lark is a very natural way to use Hermes.

The official documentation recommends this entry command for Feishu/Lark:

Terminal window
hermes gateway setup

After you run it, simply choose Feishu / Lark in the wizard.

The Feishu documentation describes two connection modes:

  • websocket: recommended
  • webhook: optional

If Hermes runs on your laptop, workstation, or private server, using websocket first is usually simpler because you do not need to expose a public callback URL.

If you configure it manually, at least know these variables

Section titled “If you configure it manually, at least know these variables”

If you are not using the wizard and are writing the configuration manually, the Feishu documentation lists these core variables:

Terminal window
FEISHU_APP_ID=cli_xxx
FEISHU_APP_SECRET=***
FEISHU_DOMAIN=feishu
FEISHU_CONNECTION_MODE=websocket
FEISHU_ALLOWED_USERS=ou_xxx,ou_yyy
FEISHU_HOME_CHANNEL=oc_xxx

Two of them deserve special attention:

  • FEISHU_ALLOWED_USERS: recommended, so not everyone who can reach the bot can use it directly
  • FEISHU_HOME_CHANNEL: lets you predefine a home chat to receive cron results or default notifications

Why Hermes sometimes does not reply in Feishu group chats

Section titled “Why Hermes sometimes does not reply in Feishu group chats”

This detail is easy to miss: in Feishu group chats, Hermes does not respond to every message by default.

The official documentation clearly states:

  • In direct messages, Hermes responds to messages
  • In group chats, you must explicitly @ the bot before it will process the message

If you want to set a Feishu conversation as the home channel, you can also use this in the chat:

/set-home

Or define it in the configuration ahead of time:

Terminal window
FEISHU_HOME_CHANNEL=oc_xxx

The Hermes commands beginners should remember first

Section titled “The Hermes commands beginners should remember first”

Whether you use Hermes in the CLI or on a messaging platform, remembering the following commands is already enough to get started:

  • /new or /reset: start a new session
  • /model: view or switch the model
  • /retry: retry the previous turn
  • /undo: undo the previous interaction
  • /compress: manually compress the context
  • /help: view help

If you mainly use Hermes on a messaging platform, remember one more:

  • /sethome or /set-home: set the current chat as the home channel

These commands cover the most common beginner-stage operations: restarting, adjusting, rolling back, checking, and continuing.

No. The current official documentation clearly states that native Windows is not supported, and WSL2 is recommended.

What should I do if typing hermes does nothing after installation?

Section titled “What should I do if typing hermes does nothing after installation?”

It is best to troubleshoot in this order:

  1. Reload your shell first, for example with source ~/.bashrc
  2. Run hermes again
  3. If it is still abnormal, run hermes doctor

Why does the bot not reply in a Feishu group?

Section titled “Why does the bot not reply in a Feishu group?”

Check these three things first:

  • Whether you @ mentioned Hermes in the group
  • Whether FEISHU_ALLOWED_USERS restricts the current user
  • Whether the current group-chat policy allows handling group messages

According to the official Feishu documentation, explicitly using an @mention is required in group-chat scenarios.

If you simply want to start using Hermes as quickly as possible, this is the most recommended order:

  1. Run the installation command first
  2. Start with hermes in the local CLI first
  3. Use hermes model and hermes setup to complete the basic configuration
  4. If you want to keep using it in Feishu, then configure hermes gateway setup

If this article is the first part of a series, its best role is not to explain every advanced feature all at once, but to get users in the door first.

The following topics are better split into follow-up articles:

  • A complete Hermes Feishu integration guide
  • A guide to common Hermes slash commands
  • A guide to Hermes gateway configuration and troubleshooting

If you plan to keep creating Hermes content, this article can serve as the starting point for later posts, while you gradually build out the internal link structure.

VSCode and code-server: Choosing a Browser-Based Code Editing Solution

VSCode and code-server: Choosing a Browser-Based Code Editing Solution

Section titled “VSCode and code-server: Choosing a Browser-Based Code Editing Solution”

When building browser-based code editing capabilities, developers face a key choice: use VSCode’s official code serve-web feature, or adopt the community-driven code-server solution? This decision affects not only the technical architecture, but also license compliance and deployment flexibility.

Technical selection is a lot like choosing a path in life. Once you pick one, you usually have to keep walking it, and switching later can become very expensive.

In the era of AI-assisted programming, browser-based code editing is becoming increasingly important. Users expect that after an AI assistant finishes analyzing code, they can immediately open an editor in the same browser session and make changes without switching applications. That kind of seamless experience should simply be there when you need it.

However, when implementing this feature, developers face a critical technical choice: should they use VSCode’s official code serve-web feature, or the community-driven code-server solution?

Each option has its own strengths and trade-offs, and choosing poorly can create a lot of trouble later. Licensing is one example: if you only discover after launch that your product is not license-compliant, it is already too late. Deployment is another: a solution might work perfectly in development, then run into all kinds of problems once moved into containers. These are exactly the kinds of pitfalls teams want to avoid.

The approach shared in this article comes from our practical experience in the HagiCode project. HagiCode is an AI-driven coding assistant. While implementing browser-based code editing, we studied both solutions in depth and ultimately designed our architecture to support both, while choosing code-server as the default.

Project repository: github.com/HagiCode-org/site

This is the most fundamental difference between the two solutions, and the first factor we considered during evaluation. When making a technical choice, it is important to understand the legal risks up front.

code-server

  • MIT license, fully open source
  • Maintained by Coder.com with an active community
  • Free to use commercially, modify, and distribute
  • No restrictions on usage scenarios

VSCode code serve-web

  • Part of the Microsoft VSCode product
  • Uses Microsoft’s license (the VS Code license includes restrictions on commercial use)
  • Primarily intended for individual developers
  • Enterprise deployment may require additional commercial licensing review

From a licensing perspective, code-server is more friendly to commercial projects. This is something you need to think through during product planning, because migrating later can become very costly.

Once licensing is settled, the next issue is deployment. That directly affects your operations cost and architectural design.

code-server

  • A standalone Node.js application that can be deployed independently
  • Supports multiple runtime sources:
    • Directly specifying the executable path
    • Looking it up through the system PATH
    • Automatic detection of an NVM Node.js 22.x environment
  • No need to install the VSCode desktop application on the server
  • Easier to deploy in containers

VSCode code serve-web

  • Must depend on a locally installed VSCode CLI
  • Requires an available code command on the host machine
  • The system filters out VS Code Remote CLI wrappers
  • Primarily designed for local development scenarios

code-server is better suited for server and container deployment scenarios. If your product needs to run in Docker, or your users do not have VSCode installed, code-server is usually the right choice.

The two solutions also differ in a few feature parameters. The differences are not huge, but they can create integration friction in real-world usage.

Featurecode-servercode serve-web
Public base path/ (configurable)/vscode-server (fixed)
Authentication--auth parameter with multiple modes--connection-token / --without-connection-token
Data directory{DataDir}/code-server{DataDir}/vscode-serve-web
TelemetryDisabled by default with --disable-telemetryDepends on VSCode settings
Update checksCan be disabled with --disable-update-checkDepends on VSCode settings

These differences need special attention during integration. For example, different URL paths mean your frontend code needs dedicated handling.

When implementing editor switching, the availability detection logic also differs.

code-server

  • Always returned as a visible implementation
  • Still shown even when unavailable, with an install-required status
  • Supports automatic detection of an NVM Node.js 22.x environment

code serve-web

  • Only visible when a local code CLI is detected
  • If unavailable, the frontend automatically hides this option
  • Depends on the local VSCode installation state

This difference directly affects the user experience. code-server is more transparent: users can see the option and understand that installation is still required. code serve-web is more hidden: users may not even realize the option exists. Which approach is better depends on the product positioning.

HagiCode’s Dual-Implementation Architecture

Section titled “HagiCode’s Dual-Implementation Architecture”

After in-depth analysis, the HagiCode project adopted a dual-implementation architecture that supports both solutions at the architectural level.

// The default active implementation is code-server
// If an explicit activeImplementation is saved, try that implementation first
// If the requested implementation is unavailable, the resolver tries the other one
// If a fallback occurs, return fallbackReason

We default to code-server mainly because of licensing and deployment flexibility. However, for users who already have a local VSCode environment, code serve-web is also a solid option.

CodeServerImplementationResolver is responsible for unifying:

  • Implementation selection during startup warm-up
  • Implementation selection when reading status
  • Implementation selection when opening projects
  • Implementation selection when opening Vaults

This design allows the system to respond flexibly to different scenarios, and users can choose the implementation that best matches their environment.

// When localCodeAvailable=false, do not show code serve-web
// When localCodeAvailable=true, show the code serve-web configuration

The frontend automatically shows available options based on the environment, so users are not confused by features they cannot use.

After all that theory, what should you pay attention to during actual deployment? In the end, implementation is what matters.

For containerized deployment, code-server is the better choice:

# Use the official code-server image directly
FROM codercom/code-server:latest
# Or install through npm
RUN npm install -g code-server

This solves the problem in a single layer without requiring an additional VSCode installation.

code-server configuration

{
"vscodeServer": {
"enabled": true,
"activeImplementation": "code-server",
"codeServer": {
"host": "0.0.0.0",
"port": 8080,
"executablePath": "",
"authMode": "none"
}
}
}

code serve-web configuration

{
"vscodeServer": {
"enabled": true,
"activeImplementation": "serve-web",
"serveWeb": {
"host": "0.0.0.0",
"port": 8080,
"executablePath": "/usr/local/bin/code"
}
}
}

Configuration can be a bit tedious the first time, but once it is in place, things become much easier to maintain.

code-server

http://localhost:8080/?folder=/path/to/project&vscode-lang=zh-CN

code serve-web

http://localhost:8080/vscode-server/?folder=/path/to/project&tkn=xxx&vscode-lang=zh-CN

Pay attention to the differences in paths and parameters. You need to handle them separately during integration.

The system supports runtime switching and automatically stops the previous implementation when switching:

// VsCodeServerManager automatically handles mutual exclusion
// When switching activeImplementation, the old implementation will not keep running in the background

This design lets users try different implementations at any time and find the option that works best for them.

const { settings, runtime } = await getVsCodeServerSettings();
// runtime.activeImplementation: "code-server" | "serve-web"
// runtime.fallbackReason: reason for switching
// runtime.status: "running" | "starting" | "stopped" | "unhealthy"

When status is visible, users can quickly determine whether a problem comes from the server side or from their own operation.

Comparison Dimensioncode-servercode serve-webRecommendation
LicenseMIT (commercial-friendly)Microsoft (restricted)code-server
Deployment flexibilityIndependent deploymentDepends on local VSCodecode-server
Server suitabilityDesigned for serversMainly for local developmentcode-server
ContainerizationNative supportRequires VSCode installationcode-server
Feature completenessClose to desktop editionOfficial complete versioncode serve-web
Maintenance activityActive communityOfficially maintained by MicrosoftBoth have strengths

Recommended strategy: Use code-server first, and consider code serve-web when you need full official functionality and already have a local VSCode environment.

The approach shared in this article is distilled from HagiCode’s real development experience. If you find this solution valuable, that is also a good sign that HagiCode itself is worth paying attention to.


If this article helped you:

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

Fast Code Editing in the Browser: VSCode Web Integration in Practice

Fast Code Editing in the Browser: VSCode Web Integration in Practice

Section titled “Fast Code Editing in the Browser: VSCode Web Integration in Practice”

After AI finishes analyzing code, how do you immediately open an editor in the browser and start making changes? This article shares our practical experience integrating code-server in the HagiCode project to create a seamless bridge between the AI assistant and the code editing experience.

In the era of AI-assisted programming, developers often need to inspect and edit code quickly. The traditional workflow is simple: open the project in a desktop IDE, locate the file, edit it, and save. But in some situations, that flow always feels slightly off.

Scenario one: remote development. When using an AI assistant like HagiCode, the backend may be running on a remote server or inside a container, and local machines cannot directly access the project files. Every time you need to inspect or modify code, you have to connect through SSH or another method, and the experience feels fragmented. It is like wanting to meet someone through a thick pane of glass: you can see them, but you cannot reach them.

Scenario two: quick previews. After the AI assistant analyzes the code, the user may only want to quickly browse a file or make a small change. Launching a full desktop IDE feels heavy, while a lightweight in-browser editor better fits the need for a “quick look.” After all, who wants to mobilize an entire toolchain just to take a glance?

Scenario three: cross-device collaboration. When working across different devices, a browser-based editor provides a unified access point without requiring every machine to be configured with a development environment. That alone saves a lot of trouble. Life is short; why repeat the same setup work over and over?

To solve these pain points, we integrated VSCode Web into the HagiCode project. This lets the AI assistant and the code editing experience connect seamlessly: after AI analyzes the code, users can immediately open an editor and make changes in the same browser session, without switching applications. It is the kind of experience where, when you need it, it is simply there.

The solution shared in this article comes from our practical experience in the HagiCode project. HagiCode is an AI-driven coding assistant designed to improve development efficiency through natural language interaction. During development, we found that users often need to switch quickly between AI analysis and code editing, which pushed us to explore how to integrate the editor directly into the browser.

Project repository: github.com/HagiCode-org/site

Among the many VSCode Web solutions available, we chose code-server. There were a few concrete reasons behind that decision.

Feature completeness. code-server is the web version of VSCode and supports most desktop features, including the extension system, intelligent suggestions, debugging, and more. That means users can get an editing experience in the browser that is very close to the desktop version. After all, who really wants to compromise on functionality?

Flexible deployment. code-server can run as an independent service and also supports Docker-based deployment, which fits well with HagiCode’s architecture. Our backend is written in C#, the frontend uses React, and the two communicate with the code-server service through REST APIs. It is like building with blocks: every piece has its place.

Secure authentication. code-server includes a built-in connection-token mechanism to prevent unauthorized access. Each session has a unique token so that only authorized users can open the editor. Security is one of those things you only fully appreciate once you have it.

HagiCode’s VSCode Web integration uses a front-end/back-end separated architecture.

The frontend wraps interactions with the backend through vscodeServerService.ts:

// Open project
export async function openProjectInCodeServer(
id: string,
currentInterfaceLanguage?: string,
): Promise<VsCodeServerLaunchResponseDto>
// Open vault
export async function openVaultInCodeServer(
id: string,
path?: string,
currentInterfaceLanguage?: string,
): Promise<VsCodeServerLaunchResponseDto>

The difference between these two methods is straightforward: openProjectInCodeServer opens the entire project, while openVaultInCodeServer opens a specific path inside a Vault. For MonoSpecs multi-repository projects, the system automatically creates a workspace file. Clear responsibilities are often enough when each part does its own job well.

The backend VaultAppService.cs implements the core logic:

public async Task<VsCodeServerLaunchResponseDto> OpenInCodeServerAsync(
string id,
string? relativePath = null,
string? currentInterfaceLanguage = null,
CancellationToken cancellationToken = default)
{
// 1. Get settings and check whether the feature is enabled
var settings = await _vsCodeServerSettingsService.GetResolvedSettingsAsync(cancellationToken);
if (!settings.Enabled) {
throw new BusinessException(VsCodeServerErrorCodes.Disabled, "VSCode Server is disabled.");
}
// 2. Get vault and resolve the launch directory
var vault = await RequireVaultAsync(id, cancellationToken);
var launchDirectory = ResolveLaunchDirectory(vault, relativePath);
// 3. Ensure code-server is running and get runtime info
var runtime = await _vsCodeServerManager.EnsureStartedAsync(settings, cancellationToken);
// 4. Resolve language settings
var language = _vsCodeServerSettingsService.ResolveLaunchLanguage(
settings.Language,
currentInterfaceLanguage);
// 5. Build launch URL
return new VsCodeServerLaunchResponseDto {
LaunchUrl = AppendQueryString(runtime.BaseUrl, new Dictionary<string, string?> {
["folder"] = launchDirectory,
["tkn"] = runtime.ConnectionToken,
["vscode-lang"] = language,
}),
ConnectionToken = runtime.ConnectionToken,
OpenMode = "folder",
Runtime = VsCodeServerSettingsService.MapRuntime(
await _vsCodeServerManager.GetRuntimeSnapshotAsync(cancellationToken)),
};
}

This method has a very clear responsibility: check settings, resolve paths, start the service, and build the URL. Among them, the ResolveLaunchDirectory method performs path security checks to prevent path traversal attacks. Code can feel a little like poetry when every line has a purpose.

The backend manages the code-server process through VsCodeServerManager:

  • Check process status
  • Automatically start stopped services
  • Return runtime snapshots such as port, process ID, and start time

This design lets the system automatically handle the code-server lifecycle, so users do not need to manage service processes manually. Life is already complicated enough; anything that can be automated should be.

HagiCode supports a multilingual interface, and code-server needs to follow that setting. The system supports three language modes:

  • follow: follow the current interface language
  • zh-CN: fixed to Chinese
  • en-US: fixed to English

The setting is passed to code-server through the vscode-lang URL parameter so that the editor language stays consistent with the HagiCode interface. Language feels best when it is unified.

For MonoSpecs projects, which contain multiple sub-repositories inside one monorepo, the system automatically creates a .code-workspace file:

private async Task<string> CreateWorkspaceFileAsync(Project project, Guid projectId)
{
var folders = await ResolveWorkspaceFoldersAsync(project.Path);
var workspaceDocument = new {
folders = folders.Select(path => new { path }).ToArray(),
};
// Generate workspace file...
}

This makes it possible to edit multiple sub-repositories in the same code-server instance, which is especially practical for large monorepo projects. Multiple repositories in one window can feel like multiple stories gathered in the same book.

The HagiCode frontend uses React + TypeScript, and integrating code-server is not especially complicated.

Add a Code Server button to the project card:

QuickActionsZone.tsx
<Button
size="sm"
variant="default"
onClick={() => onAction({ type: 'open-code-server' })}
>
<Globe className="h-3 w-3 mr-1" />
<span className="text-xs">{t('project.openCodeServer')}</span>
</Button>

This button triggers the open action and calls the backend API to obtain the launch URL. One button, one action, direct and simple.

const handleAction = async (action: ProjectAction) => {
if (action.type === 'open-code-server') {
const response = await openProjectInCodeServer(project.id, i18n.language);
window.open(response.launchUrl, '_blank', 'noopener,noreferrer');
}
};

Use window.open to open code-server in a new tab. The noopener,noreferrer parameters provide extra security. When it comes to security, there is no such thing as being too careful.

Add a similar edit button in the Vault list:

const handleEditVault = async (vault: VaultItemDto) => {
const response = await openVaultInCodeServer(vault.id);
window.open(response.launchUrl, '_blank', 'noopener,noreferrer');
};

Projects and Vaults use the same open mechanism, which keeps the interaction consistent. Consistency matters almost as much as the feature itself.

The URL format for code-server has a few details worth noting.

Folder mode:

http://{host}:{port}/?folder={path}&tkn={token}&vscode-lang={lang}

Workspace mode:

http://{host}:{port}/?workspace={workspacePath}&tkn={token}&vscode-lang={lang}

Here, tkn is the connection token. It is generated automatically every time code-server starts, ensuring secure access. The vscode-lang parameter controls the editor UI language. Every one of these parameters has a role to play.

The user talks with HagiCode, the AI analyzes the project code and finds a potential issue, and then the user clicks the “Open in Code Server” button to open the editor directly in the browser, inspect the affected file, fix it, and return to HagiCode to continue the conversation. The entire flow happens in the browser without switching applications. It feels smooth in the way running water feels smooth.

Scenario Two: Editing Study Materials in a Vault

Section titled “Scenario Two: Editing Study Materials in a Vault”

A user creates a Vault for studying an open source project and wants to add study notes under the docs/ directory. With code-server, they can edit Markdown files directly in the browser, save them, and let HagiCode immediately read the updated notes. This is especially useful for building a personal knowledge base. Knowledge only becomes more valuable the more you accumulate it.

Scenario Three: MonoSpecs Multi-Repository Development

Section titled “Scenario Three: MonoSpecs Multi-Repository Development”

A MonoSpecs project contains multiple sub-repositories, and code-server automatically creates a multi-folder workspace. In the browser, users can edit code across several repositories at once and then commit changes back to their respective Git repositories. This workflow is particularly well suited for changes that need to span multiple repositories. Editing several repositories together takes a bit of technique, just like handling multiple tasks at the same time.

When implementing code-server integration, security deserves special attention. If security goes wrong, you always notice too late.

The connection-token is generated randomly and should not be exposed. It is best used under HTTPS to prevent the token from being intercepted by a man-in-the-middle. Sensitive information is worth protecting properly.

The backend implements path traversal checks:

private static string ResolveLaunchDirectory(VaultRegistryEntry vault, string? relativePath)
{
var vaultRoot = EnsureTrailingSeparator(Path.GetFullPath(vault.PhysicalPath));
var combinedPath = Path.GetFullPath(Path.Combine(vaultRoot, relativePath ?? "."));
if (!combinedPath.StartsWith(vaultRoot, StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException(VaultRelativePathTraversalCode, "Relative path traversal detected.");
}
return combinedPath;
}

This code ensures that users cannot use ../ or similar patterns to access files outside the Vault directory. Boundary checks are always better done than skipped.

The code-server process should run with appropriate user permissions so that it cannot access sensitive system files. It is best to run the code-server service under a dedicated user. Permission control is one of those fundamentals you should always keep in place.

code-server consumes server resources, so here are a few optimization suggestions:

  • Monitor CPU and memory usage, and adjust resource limits when necessary
  • Large projects may require longer timeouts
  • Implement automatic session timeout cleanup to release resources
  • Consider caching to reduce repeated computation

HagiCode provides a runtime status monitoring API, and the frontend can call getVsCodeServerSettings() to retrieve the current state:

const { settings, runtime } = await getVsCodeServerSettings();
// runtime.status: 'disabled' | 'stopped' | 'starting' | 'running' | 'unhealthy'
// runtime.baseUrl: "http://localhost:8080"
// runtime.processId: 12345

This design allows users to clearly understand the health status of code-server and quickly locate problems when something goes wrong. When the status is visible, people feel more in control.

During implementation, we discovered a few details that noticeably affect the user experience and deserve extra attention.

Opening code-server for the first time may require waiting for startup, and that delay can range from a few seconds to half a minute. It is a good idea to show a loading state in the frontend so users know the system is still working. Waiting is easier when there is feedback.

Browsers may block the popup, so users should be prompted to allow it manually. On first launch, HagiCode displays guidance that explains how to grant the necessary browser permissions. User experience often lives in exactly these small details.

It is also a good idea to display runtime status such as starting, running, or error, so that when problems occur, users can quickly tell whether the issue is on the server side or in their own operation. Knowing where the problem is at least gives you a place to start.

The configuration for code-server is not complicated:

{
"vscodeServer": {
"enabled": true,
"host": "0.0.0.0",
"port": 8080,
"language": "follow"
}
}

enabled controls whether the feature is turned on, host and port define the listening address, and language sets the language mode. These settings can be modified through the UI and take effect immediately. Simple things are often the easiest to use.

HagiCode’s VSCode Web integration provides an elegant solution: it lets the AI assistant and the code editing experience connect seamlessly. By integrating code-server into the browser, users can quickly act on AI analysis results and complete the full flow from analysis to editing in the same browser session.

This solution brings several key advantages: a unified experience, because projects and Vaults use the same open mechanism; multi-repository support, because MonoSpecs projects automatically create workspaces; and controllable security, thanks to runtime status monitoring and path safety checks.

The approach shared in this article is something HagiCode distilled from real development work. If you find this solution valuable, that suggests our engineering practice is doing something right, and HagiCode itself may be worth a closer look. Good tools deserve to be seen by more people.

  • HagiCode GitHub: github.com/HagiCode-org/site
  • HagiCode official website: hagicode.com
  • code-server official website: coder.com/code-server
  • Related code files:
    • repos/web/src/services/vscodeServerService.ts
    • repos/hagicode-core/src/PCode.Application/Services/VaultAppService.cs
    • repos/hagicode-core/src/PCode.Application/ProjectAppService.VsCodeServer.cs

If this article helped you:

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

Guide to Creating a Border Light Sweep Animation Effect

Guide to Creating a Border Light Sweep Animation Effect

Section titled “Guide to Creating a Border Light Sweep Animation Effect”

How do you build that important element users notice at a glance using pure CSS? It is actually not that hard. The trick is just taking a slightly roundabout path. In this article, I will walk you through how to build a border light sweep animation from scratch, and also share a few of the pitfalls we ran into while building HagiCode.

If you work on the frontend, you have probably had this experience before: a product manager walks over with that “this is definitely simple” expression and says, “Can we add some kind of special effect to this running task so users can spot it immediately?”

You say sure, we can just change the border color. Then they shake their head with that look that says, “You do not get it.” They reply, “That is not obvious enough. I want the kind of effect where light runs around the border, like in a sci-fi movie.”

At that point you might start wondering how to build it. Canvas? SVG? Or can CSS handle it on its own? After all, nobody wants to admit they do not know how.

In modern web applications, border light sweep animations are actually very common. They are mainly used in a few scenarios like these:

  • Status indicators: Marking tasks in progress or active items
  • Visual focus: Highlighting important content areas
  • Brand enhancement: Creating a sleek, modern, tech-forward visual style
  • Seasonal themes: Building a celebratory atmosphere for special occasions

We ran into exactly this requirement while building HagiCode. Users needed to see at a glance which sessions were running and which proposals were currently being processed. We tried several different approaches. Some paths were smoother, some were a bit more winding. In the end, we settled on a fairly mature implementation strategy.

The approach shared in this article comes from our hands-on experience in the HagiCode project. HagiCode is an AI-driven coding assistant project, and the interface makes extensive use of border light animations to indicate different runtime states. Examples include the running state of items in the session list, status transitions in the proposal flow diagram, and intensity indicators for throughput.

These effects are not especially complicated in principle, but we definitely ran into plenty of pitfalls while implementing them. If you want to see the real thing, you can visit our GitHub repository or head to the official website. In the end, what matters most is what actually works.

After analyzing the HagiCode codebase, we summarized several core implementation patterns below. Each one fits a different scenario, or in other words, each one exists for a reason.

1. Rotating glow with a conic gradient (most common)

Section titled “1. Rotating glow with a conic gradient (most common)”

This is the classic way to implement a border light sweep effect. The core idea is to use CSS conic-gradient to create a conic gradient, then rotate it continuously. Like a streetlight turning in the night, it just keeps circling.

Key elements:

  • Use the ::before pseudo-element to create the glow layer
  • Use conic-gradient to define the gradient color distribution
  • Use the ::after pseudo-element to mask the center area (optional)
  • Use @keyframes to implement the rotation animation

This works well for status indicators in list items. You create a thin glowing line on one side of the element instead of animating the entire border. Sometimes a little light is enough. You do not need to illuminate the whole world.

Key elements:

  • A thin absolutely positioned line element
  • Use box-shadow to create the glow effect
  • Use scale and opacity for a breathing animation

If you do not need the full sweep effect and just want a soft background glow, layering multiple box-shadow values is enough. Sometimes the simpler option is the better one.

This part is easy to overlook, but it is extremely important. Every animation should account for the prefers-reduced-motion media query and provide a static alternative for users who do not want animation. Not everyone enjoys constant motion, and respecting that preference matters.

Section titled “Option 1: Rotating conic-gradient border (recommended)”

This is the most complete implementation of the sweeping border light effect, and it is also the option we use most often in HagiCode. After all, if something works well, why replace it?

/* Parent container */
.glow-border-container {
position: relative;
overflow: hidden;
}
/* Rotating glow layer */
.glow-border-container::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: conic-gradient(
transparent 0deg,
rgba(59, 130, 246, 0.6) 60deg,
rgba(59, 130, 246, 0.3) 120deg,
rgba(59, 130, 246, 0.6) 180deg,
transparent 240deg
);
animation: border-rotate 3s linear infinite;
z-index: -1;
}
/* Mask layer (optional, for creating a hollow border effect) */
.glow-border-container::after {
content: '';
position: absolute;
inset: 2px;
background: inherit;
border-radius: inherit;
z-index: -1;
}
@keyframes border-rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

The principle behind this option is fairly simple: create a pseudo-element larger than the parent container, draw a conic gradient on it, and rotate it continuously. The parent container uses overflow: hidden, so only the light passing around the border remains visible. It is a bit like watching a streetlight through a window. You only ever see the small slice that passes by.

Option 2: Simplified rotating light border

Section titled “Option 2: Simplified rotating light border”

If you do not need the full effect, HagiCode also includes a lighter utility-class version. Sometimes the simpler approach really is better.

/* Rotating light border utility class */
.running-light-border {
position: absolute;
inset: -2px;
background: conic-gradient(
from 0deg,
transparent 0deg 270deg,
var(--theme-running-color) 270deg 360deg
);
border-radius: inherit;
animation: lightRayRotate 3s linear infinite;
will-change: transform;
z-index: 0;
}
@keyframes lightRayRotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Accessibility support */
@media (prefers-reduced-motion: reduce) {
.running-light-border {
animation: none;
}
}

Notice the will-change: transform here. It tells the browser, “This element is going to keep changing,” so the browser can prepare some optimizations ahead of time and keep the animation smoother. Preparing in advance is usually better than scrambling at the last minute.

This is especially suitable for list-item status indicators, and it is exactly what the HagiCode session list uses. One thin line can still stand out among many items. That feels like a life lesson in its own way.

.side-glow {
position: relative;
isolation: isolate;
}
.side-glow::before {
content: '';
position: absolute;
left: 0;
top: 14px;
bottom: 14px;
width: 1px;
border-radius: 999px;
background: var(--theme-running-color);
box-shadow:
0 0 16px var(--theme-running-color),
0 0 28px var(--theme-running-color);
z-index: 1;
pointer-events: none;
animation: sidePulse 2.6s ease-in-out infinite;
}
.side-glow > * {
position: relative;
z-index: 2;
}
@keyframes sidePulse {
0%, 100% {
opacity: 0.55;
transform: scaleY(0.96);
}
50% {
opacity: 0.95;
transform: scaleY(1);
}
}

This uses isolation: isolate to create a new stacking context, then relies on z-index to control the display order of each layer. pointer-events: none is also essential. Otherwise the pseudo-element would block user clicks. Some things can look nice, but they still should not get in the way.

If your project uses React, you can wrap this logic in a component, especially the accessibility handling. Write it once, use it many times. That is the whole point.

import React from 'react';
import { useReducedMotion } from 'framer-motion';
import styles from './GlowBorder.module.css';
interface GlowBorderProps {
isActive: boolean;
children: React.ReactNode;
className?: string;
}
export const GlowBorder = React.memo<GlowBorderProps>(
({ isActive, children, className = '' }) => {
const prefersReducedMotion = useReducedMotion();
if (!isActive) {
return <div className={className}>{children}</div>;
}
if (prefersReducedMotion) {
return (
<div className={`${styles.glowStatic} ${className}`}>
{children}
</div>
);
}
return (
<div className={`${styles.glowAnimated} ${className}`}>
{children}
</div>
);
}
);

The matching CSS module:

GlowBorder.module.css
/* Animated version */
.glowAnimated {
position: relative;
overflow: hidden;
}
.glowAnimated::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: conic-gradient(
from 0deg,
transparent,
rgba(59, 130, 246, 0.6),
transparent,
rgba(59, 130, 246, 0.6),
transparent
);
animation: rotateGlow 3s linear infinite;
z-index: -1;
}
.glowAnimated::after {
content: '';
position: absolute;
inset: 2px;
background: inherit;
border-radius: inherit;
z-index: -1;
}
/* Static version (accessibility) */
.glowStatic {
position: relative;
border: 1px solid rgba(59, 130, 246, 0.5);
box-shadow: 0 0 15px rgba(59, 130, 246, 0.3);
}
@keyframes rotateGlow {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

The useReducedMotion hook from framer-motion automatically detects the user’s system preference. If the user has enabled reduced motion, it returns true, and the component shows the static version instead. Respecting the user’s preference matters more than forcing a flashy effect.

These are some of the lessons we learned while building HagiCode. You could also call them battle scars. Hopefully they help you avoid some detours.

CSS variables make multi-theme support especially convenient. Nobody wants to edit a pile of code every time the theme changes.

:root {
--glow-color-light: rgb(16, 185, 129);
--glow-color-dark: rgb(16, 185, 129);
--theme-glow-color: var(--glow-color-light);
}
html.dark {
--theme-glow-color: var(--glow-color-dark);
}
/* Usage */
.glow-effect {
background: var(--theme-glow-color);
box-shadow: 0 0 20px var(--theme-glow-color);
}

That way, switching themes only requires changing the class on the html element, and every animation color updates automatically. One codebase, two styles. That is exactly what we want.

Use will-change to hint the browser to optimize:

.animated-glow {
will-change: transform, opacity;
}

Tell the browser in advance, and it will help you optimize. A lot of things in life work better with a little preparation.

Avoid using complex box-shadows on large elements:

/* Not ideal - using a blurred shadow on a large element */
.large-card {
box-shadow: 0 0 50px rgba(0, 0, 0, 0.5);
}
/* Better - use a pseudo-element to limit the glowing area */
.large-card::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: 0 0 20px var(--glow-color);
pointer-events: none;
}

We tested this in HagiCode. Adding a blurry shadow directly to a large card dropped scrolling frame rates below 30fps. Switching to a pseudo-element brought things back to a steady 60fps. Users can absolutely feel that difference.

You really should not skip this. Some users find animation dizzying or distracting, and respecting their preferences is part of building a good product. Beautiful things do not need to be imposed on everyone.

CSS media query:

@media (prefers-reduced-motion: reduce) {
.glow-animation {
animation: none;
}
.glow-animation::before {
/* Provide a static fallback */
opacity: 1;
}
}

Detect user preference in React:

import { useReducedMotion } from 'framer-motion';
const Component = () => {
const prefersReducedMotion = useReducedMotion();
return (
<div className={prefersReducedMotion ? 'static-glow' : 'animated-glow'}>
Content
</div>
);
};

The Token throughput indicator in HagiCode shows different glow colors based on real-time throughput, and this is implemented dynamically. Different states should be expressed differently.

const colors = [
null, // Level 0 - no color
'#3b82f6', // Level 1 - Blue
'#34d399', // Level 2 - Emerald
'#facc15', // Level 3 - Yellow
'#fbbf24', // Level 4 - Amber
'#f97316', // Level 5 - Orange
'#22d3ee', // Level 6 - Cyan
'#d946ef', // Level 7 - Fuchsia
'#f43f5e', // Level 8 - Rose
];
const IntensityGlow = ({ intensity }) => {
const glowColor = colors[Math.min(intensity, colors.length - 1)];
return (
<div
className="glow-effect"
style={{
'--glow-color': glowColor,
opacity: 0.6 + (intensity * 0.08),
}}
/>
);
};

There are still a few details worth paying attention to, because by the time you discover these problems the hard way, it is already too late.

Things to watch out forExplanation
z-index managementThe glow layer should use an appropriate z-index so it does not interfere with content interaction
pointer-eventsThe glow pseudo-element should set pointer-events: none
Boundary overflowThe parent container needs overflow: hidden, or you need to adjust pseudo-element sizing
Performance impactComplex animations can hurt performance on mobile devices, so test carefully
Dark modeMake sure the glow color remains clearly visible on dark backgrounds
Theme switchingUse CSS variables so animation colors update correctly when the theme changes

Pseudo-elements can be a little hard to locate in developer tools, so you can temporarily add a border to check the position.

/* Temporarily show pseudo-element boundaries for debugging */
.glow-effect::before {
/* debug: border: 1px solid red; */
}

After you finish positioning it, remember to comment out or remove that line. Otherwise production can get awkward pretty quickly. Some things are better left in development.

Border light sweep animations are neither especially hard nor truly trivial. At the core, the formula is conic-gradient plus rotation, but if you want good performance, maintainability, and accessibility, there are still plenty of implementation details to handle carefully.

HagiCode hit a lot of these pitfalls and gradually distilled a set of best practices. That is just how projects go: you experiment, make mistakes, and improve one step at a time. If you are building something similar, I hope this article helps you avoid a few unnecessary detours.

Some things only become clear once you build them yourself.

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

Building a Cross-Project Knowledge Base for the AI Era with the Vault System

Building a Cross-Project Knowledge Base for the AI Era with the Vault System

Section titled “Building a Cross-Project Knowledge Base for the AI Era with the Vault System”

Learning by studying and reproducing real projects is becoming mainstream, but scattered learning materials and broken context make it hard for AI assistants to deliver their full value. This article introduces the Vault system design in the HagiCode project: through a unified storage abstraction layer, AI assistants can understand and access all learning resources, enabling true cross-project knowledge reuse.

In fact, in the AI era, the way we learn new technologies is quietly changing. Traditional approaches like reading books and watching videos still matter, but “studying and reproducing projects” - deeply researching and learning from the code, architecture, and design patterns of excellent open source projects - is clearly becoming more efficient. Running and modifying high-quality open source projects directly is one of the fastest ways to understand real-world engineering practice.

But this approach also brings new challenges.

Learning materials are too scattered. Notes might live in Obsidian, code repositories may be spread across different folders, and an AI assistant’s conversation history becomes a separate data island. Every time you need AI help analyzing a project, you have to manually copy code snippets and organize context, which is quite tedious.

Context keeps getting lost. AI assistants cannot directly access local learning resources, so every conversation starts with re-explaining background information. The code repositories you study update quickly, and manual synchronization is error-prone. Worse still, knowledge is hard to share across multiple learning projects - the design patterns learned in project A are completely unknown to the AI when it works on project B.

At the core, these issues are all forms of “data islands.” If there were a unified storage abstraction layer that let AI assistants understand and access all learning resources, the problem would be solved.

To address these pain points, we made a key design decision while developing HagiCode: build a Vault system as a unified knowledge storage abstraction layer. The impact of that decision may be even greater than you expect - more on that shortly.

The approach shared in this article comes from practical experience in the HagiCode project. HagiCode is an AI coding assistant based on the OpenSpec workflow. Its core idea is that AI should not only be able to “talk,” but also be able to “do” - directly operate on code repositories, execute commands, and run tests. GitHub: github.com/HagiCode-org/site

During development, we found that AI assistants need frequent access to many kinds of user learning resources: code repositories, notes, configuration files, and more. If users had to provide everything manually each time, the experience would be terrible. That led us to design the Vault system.

HagiCode’s Vault system supports four types, each corresponding to different usage scenarios:

TypePurposeTypical Scenario
folderGeneral-purpose folder typeTemporary learning materials, drafts
coderefDesigned specifically for studying code projectsSystematically learning an open source project
obsidianIntegrates with Obsidian note-taking softwareReusing an existing notes library
system-managedManaged automatically by the systemProject configuration, prompt templates, and more

Among them, the coderef type is the most commonly used in HagiCode. It provides a standardized directory structure and AI-readable metadata descriptions for code-study projects. Why design this type specifically? Because studying an open source project is not as simple as “downloading code.” You also need to manage the code itself, learning notes, configuration files, and other content at the same time, and coderef standardizes all of that.

The Vault registry is persisted to the file system as JSON:

_registryFilePath = Path.Combine(absoluteDataDir, "personal-data", "vaults", "registry.json");

This design may look simple, but it was carefully considered:

Simple and reliable. JSON is human-readable, making it easy to debug and modify manually. When something goes wrong, you can open the file directly to inspect the state or even repair it by hand - especially useful during development.

Reduced dependencies. File system storage avoids the complexity of a database. There is no need to install and configure an extra database service, which reduces system complexity and maintenance cost.

Concurrency-safe. SemaphoreSlim is used to guarantee thread safety. In an AI coding assistant scenario, multiple operations may access the Vault registry at the same time, so concurrency control is necessary.

The system’s core capability is that it can automatically inject Vault information into the context of AI proposals:

export function buildTargetVaultsText(
vaults: VaultForText[],
template: VaultPromptTemplate = DEFAULT_VAULT_PROMPT_TEMPLATE,
): string {
const readOnlyVaults = vaults.filter((vault) => vault.accessType === 'read');
const editableVaults = vaults.filter((vault) => vault.accessType === 'write');
const sections = [
buildVaultSection(readOnlyVaults, template.reference),
buildVaultSection(editableVaults, template.editable),
].filter(Boolean);
return `\n\n### ${template.heading}\n\n${sections.join('\n')}`;
}

This allows the AI assistant to automatically understand which learning resources are available, without requiring the user to provide context manually every time. It makes the HagiCode experience feel especially natural - tell the AI, “Help me analyze React concurrent rendering,” and it can automatically find the previously registered React learning Vault instead of asking you to paste code over and over again.

The system divides Vaults into two access types:

  • reference (read-only): AI can only use the content for analysis and understanding, without modifying it
  • editable (modifiable): AI can modify the content as needed for the task

This distinction tells the AI which content is “read-only reference” and which content it is allowed to modify, reducing the risk of accidental changes. For example, if you register an open source project’s Vault as learning material, you definitely do not want AI casually editing the code inside it - so mark it as reference. But if it is your own project Vault, you can mark it as editable and let AI help modify the code.

Standardized Structure for a CodeRef Vault

Section titled “Standardized Structure for a CodeRef Vault”

For coderef Vaults, the system provides a standardized directory structure:

my-coderef-vault/
├── index.yaml # vault metadata description
├── AGENTS.md # operating guide for AI assistants
├── docs/ # stores learning notes and documentation
└── repos/ # manages referenced code repositories through Git submodules

What is the design philosophy behind this structure?

docs/ stores learning notes, using Markdown to record your understanding of the code, architecture analysis, and lessons from debugging. These notes are not only for you - AI can understand them too, and will automatically reference them when handling related tasks.

repos/ manages the studied repositories through Git submodules rather than by copying code directly. This has two benefits: first, it stays in sync with upstream, and a single git submodule update fetches the latest code; second, it saves space, because multiple Vaults can reference different versions of the same repository.

index.yaml contains Vault metadata so the AI assistant can quickly understand its purpose and contents. It is essentially a “self-introduction” for the Vault, so the AI knows what it is for the first time it sees it.

AGENTS.md is a guide written specifically for AI assistants, explaining how to handle the content inside the Vault. You can tell the AI things like: “When analyzing this project, focus on code related to performance optimization” or “Do not modify test files.”

Creating a CodeRef Vault is simple:

const createCodeRefVault = async () => {
const response = await VaultService.postApiVaults({
requestBody: {
name: "React Learning Vault",
type: "coderef",
physicalPath: "/Users/developer/vaults/react-learning",
gitUrl: "https://github.com/facebook/react.git"
}
});
// The system will automatically:
// 1. Clone the React repository to vault/repos/react
// 2. Create the docs/ directory for notes
// 3. Generate index.yaml metadata
// 4. Create the AGENTS.md guide file
return response;
};

Then reference this Vault in an AI proposal:

const proposal = composeProposalChiefComplaint({
chiefComplaint: "Help me analyze React's concurrent rendering mechanism",
repositories: [
{ id: "react", gitUrl: "https://github.com/facebook/react.git" }
],
vaults: [
{
id: "react-learning",
name: "React Learning Vault",
type: "coderef",
physicalPath: "/vaults/react-learning",
accessType: "read" // AI can only read, not modify
}
],
quickRequestText: "Pay special attention to the Fiber architecture and scheduler implementation"
});

Scenario 1: Systematically studying open source projects

Create a CodeRef Vault, manage the target repository through Git submodules, and record learning notes in the docs/ directory. AI can access both the code and the notes at the same time, providing more accurate analysis. Notes written while studying a module are automatically referenced by the AI when it later analyzes related code - like having an “assistant” that remembers your previous thinking.

Scenario 2: Reusing an Obsidian notes library

If you are already using Obsidian to manage notes, just register your existing Vault in HagiCode directly. AI can access your knowledge base without manual copy-paste. This feature is especially practical because many people have years of accumulated notes, and once connected, AI can “read” and understand that knowledge system.

Scenario 3: Cross-project knowledge reuse

Multiple AI proposals can reference the same Vault, enabling knowledge reuse across projects. For example, you can create a “design patterns learning Vault” that contains notes and code examples for many design patterns. No matter which project the AI is analyzing, it can refer to the content in that Vault - knowledge does not need to be accumulated repeatedly.

The system strictly validates paths to prevent path traversal attacks:

private static string ResolveFilePath(string vaultRoot, string relativePath)
{
var rootPath = EnsureTrailingSeparator(Path.GetFullPath(vaultRoot));
var combinedPath = Path.GetFullPath(Path.Combine(rootPath, relativePath));
if (!combinedPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException(VaultRelativePathTraversalCode,
"Vault file paths must stay inside the registered vault root.");
}
return combinedPath;
}

This ensures all file operations stay within the Vault root directory and prevents malicious path access. Security is not something to take lightly. If an AI assistant is going to operate on the file system, the boundaries must be clearly defined.

When using the HagiCode Vault system, there are several things to pay special attention to:

  1. Path safety: Make sure custom paths stay within the allowed scope, otherwise the system will reject the operation. This prevents accidental misuse and potential security risks.

  2. Git submodule management: CodeRef Vaults are best managed with Git submodules instead of directly copying code. The benefits were covered earlier - keeping in sync and saving space. That said, submodules have their own workflow, so first-time users may need a little time to get familiar with them.

  3. File preview limits: The system limits file size (256KB) and quantity (500 files), so oversized files need to be handled in batches. This limit exists for performance reasons. If you run into very large files, you can split them manually or process them another way.

  4. Diagnostic information: Creating a Vault returns diagnostic information that can be used for debugging on failure. Check the diagnostics first when you run into issues - in most cases, that is where you will find the clue.

The HagiCode Vault system is fundamentally solving a simple but profound problem: how to let AI assistants understand and use local knowledge resources.

Through a unified storage abstraction layer, a standardized directory structure, and automated context injection, it delivers a knowledge management model of “register once, reuse everywhere.” Once a Vault is created, AI can automatically access and understand learning notes, code repositories, and documentation resources.

The experience improvement from this design is obvious. There is no longer any need to manually copy code snippets or repeatedly explain background information - the AI assistant becomes more like a teammate who truly understands the project and can provide more valuable help based on existing knowledge.

The Vault system shared in this article is a solution shaped through real trial and error and real optimization during HagiCode development. If you think this design is valuable, that says something about the engineering behind it - and HagiCode itself is worth checking out as well.

If this article helped you:

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 like, save, and share it. This content was created with AI-assisted collaboration, and the final version was reviewed and confirmed by the author.

Edit DESIGN.md Directly in the Web Interface: From Idea to Implementation

Edit DESIGN.md Directly in the Web Interface: From Idea to Implementation

Section titled “Edit DESIGN.md Directly in the Web Interface: From Idea to Implementation”

In the MonoSpecs project management system, DESIGN.md carries the architectural design and technical decisions of a project. But the traditional editing workflow forces users to jump out to an external editor. That fragmented experience is like being interrupted in the middle of reading a poem: the inspiration is gone, and so is the mood. This article shares the solution we put into practice in the HagiCode project: editing DESIGN.md directly in the web interface, with support for importing templates from an online design site. After all, who does not enjoy the feeling of completing everything in one flow?

As the core carrier of project design documents, DESIGN.md holds key information such as architecture design, technical decisions, and implementation guidance. However, the traditional editing approach requires users to switch to an external editor such as VS Code, manually locate the physical path, and then edit the file. It is not especially complicated, but after repeating the process a few times, it becomes tiring.

The problems mainly show up in the following ways:

  • Fragmented workflow: users must constantly switch between the web management interface and a local editor, breaking the continuity of their workflow, much like having the music cut out in the middle of a song.
  • Hard to reuse: the design site already publishes a rich library of design templates, but they cannot be integrated directly into the project editing workflow. The good stuff exists, but you still cannot use it where you need it.
  • Missing experience loop: there is no closed loop for “preview-select-import,” so users must copy and paste manually, which increases the risk of mistakes.
  • Collaboration friction: keeping design documents and code implementation in sync becomes a high-friction process, which hurts team efficiency.

To solve these pain points, we decided to add direct editing support for DESIGN.md in the web interface and allow one-click template import from an online design site. It was not some earth-shaking decision. We simply wanted to make the development experience smoother.

The solution shared in this article comes from our hands-on experience in the HagiCode project. HagiCode is an AI-driven coding assistant project, and during development we frequently need to maintain project design documents. To help the team collaborate more efficiently, we explored and implemented this online editing and import solution. There is nothing mysterious about it. We ran into a problem and worked out a way to solve it.

This solution uses a frontend-backend separated architecture with a same-origin proxy, mainly composed of the following layers. In practice, the design can be summed up as “each part doing its own job”:

1. Frontend editor layer

repos/web/src/components/project/DesignMdManagementDrawer.tsx
// Core component: DesignMdManagementDrawer
// Responsibility: handle editing, saving, version conflict detection, and import flow

2. Backend service layer

ProjectAppService.DesignMd
// Location: repos/hagicode-core/src/PCode.Application/ProjectAppService.DesignMd.cs
// Responsibility: path resolution, file read/write, and version management

3. Same-origin proxy layer

ProjectAppService.DesignMdSiteIndex
// Location: repos/hagicode-core/src/PCode.Application/ProjectAppService.DesignMdSiteIndex.cs
// Responsibility: proxy design site resources, preview image caching, and security validation

We use a single global drawer instead of local pop-up layers, with state managed through layoutSlice, which gives users a consistent experience across views (classic and kanban). No matter which view the user opens the editor from, they get the same interaction model. A consistent experience makes people feel more at ease instead of getting disoriented when they switch views.

We mounted DESIGN.md-related endpoints under ProjectController, reusing the existing project permission boundary and avoiding the complexity of adding a separate controller. This makes permission handling clearer and also aligns with RESTful resource organization. Sometimes reuse is more meaningful than creating something new from scratch.

We derive an opaque version from the file system’s LastWriteTimeUtc, which gives us lightweight optimistic concurrency control. When multiple users edit the same file at once, the system can detect conflicts and prompt the user to refresh. This design does not block editing, while still protecting data consistency.

We use IHttpClientFactory to proxy external design-site resources, avoiding both cross-origin issues and SSRF risks. This keeps the system secure while also simplifying frontend calls. You can hardly be too careful with security.

The backend is mainly responsible for path resolution, file read/write, and version management. These tasks are basic, but indispensable, like the foundation of a house:

// Path resolution and security validation
private Task<string> ResolveDesignDocumentDirectoryAsync(string projectPath, string? repositoryPath)
{
if (string.IsNullOrWhiteSpace(repositoryPath))
{
return Task.FromResult(Path.GetFullPath(projectPath));
}
return ValidateSubPathAsync(projectPath, repositoryPath);
}
// Version generation (based on file system timestamp and size)
private static string BuildDesignDocumentVersion(string path)
{
var fileInfo = new FileInfo(path);
fileInfo.Refresh();
return string.Create(
CultureInfo.InvariantCulture,
$"{fileInfo.LastWriteTimeUtc.Ticks:x}-{fileInfo.Length:x}");
}

The version design is interesting in its simplicity: we use the file’s last modified time and size to generate a unique version identifier. It is lightweight and reliable, with no extra version database to maintain. Simple solutions are often the most effective.

On the frontend, we implement dirty-state detection and save logic. This design helps users understand whether their changes have been saved and reduces the anxiety of “what if I lose it?”:

// Dirty-state detection and save logic
const [draft, setDraft] = useState('');
const [savedDraft, setSavedDraft] = useState('');
const isDirty = draft !== savedDraft;
const handleSave = useCallback(async () => {
const result = await saveProjectDesignMdDocument({
...activeTarget,
content: draft,
expectedVersion: document.version, // optimistic concurrency control
});
setSavedDraft(draft); // update saved state
}, [activeTarget, document, draft]);

In this implementation, we maintain two pieces of state: draft is the content currently being edited, while savedDraft is the saved content. Comparing them tells us whether there are unsaved changes. The design is simple, but it gives people peace of mind. Nobody wants the thing they worked hard on to disappear.

2. Import Design Files from an Online Source

Section titled “2. Import Design Files from an Online Source”
repos/index/
└── src/data/public/design.json # Design template index
repos/awesome-design-md-site/
├── vendor/awesome-design-md/ # Upstream design templates
│ └── design-md/
│ ├── clickhouse/
│ │ └── DESIGN.md
│ ├── linear/
│ │ └── DESIGN.md
│ └── ...
└── src/lib/content/
└── awesomeDesignCatalog.ts # Content pipeline

The index file on the design site defines all available templates. With this index, users can choose the template they want as easily as ordering from a menu:

{
"entries": [
{
"slug": "linear.app",
"title": "Linear Inspired Design System",
"summary": "AI Product / Dark Feel",
"detailUrl": "/designs/linear.app/",
"designDownloadUrl": "/designs/linear.app/DESIGN.md",
"previewLightImageUrl": "...",
"previewDarkImageUrl": "..."
}
]
}

Each entry includes the template’s basic information and download links. The backend reads the list of available templates from this index and presents them for the user to choose from. That makes selection intuitive instead of forcing people to feel their way around in the dark.

To keep things secure, the backend performs strict validation on access to the design site. You cannot be too cautious about security:

// Safe slug validation
private static readonly Regex SafeDesignSiteSlugRegex =
new("^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,127})$", RegexOptions.Compiled);
private static string NormalizeDesignSiteSlug(string slug)
{
var normalizedSlug = slug?.Trim() ?? string.Empty;
if (!IsSafeDesignSiteSlug(normalizedSlug))
{
throw new BusinessException(
ProjectDesignSiteIndexErrorCodes.InvalidSlug,
"Design site slug must be a single safe path segment.");
}
return normalizedSlug;
}
// Preview image caching (OS temp directory)
private static string ComputePreviewCacheKey(string slug, string theme, string previewUrl)
{
var raw = $"{slug}|{theme}|{previewUrl}";
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
return Convert.ToHexString(bytes).ToLowerInvariant();
}

We do two things here: first, we validate the slug format strictly with a regular expression to prevent path traversal attacks; second, we cache preview images to reduce pressure on the external site. The former is protection, the latter is optimization, and both matter.

// 1. Open the import drawer
const handleRequestImportDrawer = useCallback(() => {
setIsImportDrawerOpen(true);
}, []);
// 2. Select and import
const handleImportRequest = useCallback((entry) => {
if (isDirty) {
setPendingImportEntry(entry);
setConfirmMode('import'); // overwrite confirmation
return;
}
void executeImport(entry);
}, [isDirty]);
// 3. Execute import
const executeImport = useCallback(async (entry) => {
const result = await getProjectDesignMdSiteImportDocument(
activeTarget.projectId,
entry.slug
);
setDraft(result.content); // replace editor text only, do not save automatically
setIsImportDrawerOpen(false);
}, [activeTarget?.projectId]);

The import flow follows a “user confirmation” principle: after import, only the editor content is updated, and nothing is saved automatically. Users can inspect the imported content and save it manually only after confirming it looks right. The final decision should stay in the hands of the user.

Scenario 1: Creating DESIGN.md in the Project Root

Section titled “Scenario 1: Creating DESIGN.md in the Project Root”

When DESIGN.md does not exist, the backend returns a virtual document state. This lets the frontend avoid special handling for the “file does not exist” case, and a unified API simplifies the code logic:

return new ProjectDesignDocumentDto
{
Path = targetPath,
Exists = false, // virtual document state
Content = string.Empty,
Version = null
};
// Automatically create the file on first save
public async Task<SaveProjectDesignDocumentResultDto> SaveDesignDocumentAsync(...)
{
Directory.CreateDirectory(targetDirectory);
await File.WriteAllTextAsync(targetPath, input.Content);
return new SaveProjectDesignDocumentResultDto { Created = !exists };
}

The benefit of this design is that the frontend does not need special-case logic for missing files. By hiding that complexity in the backend, the frontend can focus more easily on user experience.

Scenario 2: Import a Template from the Design Site

Section titled “Scenario 2: Import a Template from the Design Site”

After the user selects the “Linear” design template in the import drawer, the system fetches the DESIGN.md content through the backend proxy. The whole process is transparent to the user: they only choose a template, and the system handles the network requests and data transformation automatically.

// 1. The system fetches DESIGN.md content through the backend proxy
GET /api/project/{id}/design-md/site-index/linear.app
// 2. The backend validates the slug and fetches content from upstream
var entry = FindDesignSiteEntry(catalog, "linear.app");
using var upstreamResponse = await httpClient.SendAsync(request);
var content = await upstreamResponse.Content.ReadAsStringAsync();
// 3. The frontend replaces the editor text
setDraft(result.content);
// The user reviews it and then saves it manually to disk

The whole flow stays transparent to the user. They just choose a template, and the system handles the networking and transformation behind the scenes. That is the experience we want: simple, but powerful.

When multiple users edit the same DESIGN.md at the same time, the system detects version conflicts. This optimistic concurrency control mechanism preserves data consistency without blocking the user’s edits:

if (!string.Equals(currentVersion, expectedVersion, StringComparison.Ordinal))
{
throw new BusinessException(
ProjectDesignDocumentErrorCodes.VersionConflict,
$"DESIGN.md at '{targetPath}' changed on disk.");
}

The frontend catches this error and prompts the user:

// Frontend prompts the user to refresh and retry
<Alert>
<AlertTitle>Version conflict</AlertTitle>
<AlertDescription>
The file was modified by another process. Please refresh to get the latest version and try again.
</AlertDescription>
</Alert>

This optimistic concurrency control mechanism keeps data consistent without blocking users while they work. Conflicts are unavoidable, but at least users should know what happened instead of silently losing their changes.

Always validate repositoryPath to prevent path traversal attacks. You can never do too much when it comes to security:

// Always validate repositoryPath to prevent path traversal attacks
return ValidateSubPathAsync(projectPath, repositoryPath);
// Reject dangerous inputs such as "../" and absolute paths

Cache preview images for 24 hours, with a maximum of 160 files. Moderate caching improves performance, but balance still matters:

// Cache preview images for 24 hours, with a maximum of 160 files
private static readonly TimeSpan PreviewCacheTtl = TimeSpan.FromHours(24);
private const int PreviewCacheMaxFiles = 160;
// Periodically clean up expired cache

Gracefully degrade when the upstream site is unavailable. This design ensures that even if an external dependency fails, the core editing functionality still works normally:

// Gracefully degrade when the upstream site is unavailable
try {
const catalog = await getProjectDesignMdSiteImportCatalog(projectId);
} catch (error) {
toast.error(t('project.designMd.siteImport.feedback.catalogLoadFailed'));
// The main editing drawer remains available
}

This graceful degradation ensures that even when external dependencies are unavailable, the core editing function continues to work. A system should be resilient instead of collapsing the moment something goes wrong.

Confirm overwrites before importing, and do not save automatically after import. Users should stay in control of their own actions:

// Confirm overwrite before import
if (isDirty) {
setConfirmMode('import');
return;
}
// Do not save automatically after import; let the user confirm
setDraft(result.content); // update draft only
// The content is written to disk only after the user reviews it and clicks Save

Use an HTTP client factory to avoid creating too many connections. Resource management may seem small, but doing it well can make a big difference:

// Use an HTTP client factory to avoid creating too many connections
private const string DesignSiteProxyClientName = "ProjectDesignSiteProxy";
private static readonly TimeSpan DesignSiteProxyTimeout = TimeSpan.FromSeconds(8);
  1. Markdown enhancement: we currently use a basic Textarea, but we could upgrade to CodeMirror for syntax highlighting and keyboard shortcuts. When the editor feels better, writing documentation feels better too.
  2. Preview mode: add real-time Markdown preview to improve the editing experience. What-you-see-is-what-you-get always gives people more confidence.
  3. Diff merge: implement an intelligent merge algorithm instead of simple full-text replacement. Conflicts are inevitable, but the conflict-resolution process does not have to be painful.
  4. Local caching: cache design.json in the database to reduce dependency on the external site. The fewer dependencies a system has, the more stable it tends to be.

In the HagiCode project, we implemented a complete online editing and import solution for DESIGN.md through frontend-backend collaboration. The core value of this solution lies in the following points:

  • Higher efficiency: no need to switch tools; editing and importing design documents can happen in one unified web interface.
  • Lower barrier to entry: one-click design template import helps new projects get started quickly.
  • Secure and reliable: path validation, version conflict detection, and graceful degradation mechanisms keep the system stable.
  • Better user experience: the global drawer, dirty-state detection, and confirmation dialogs refine the overall interaction experience.

This solution is already running in the HagiCode project and has solved the team’s pain points around design document management. If you are facing similar problems, I hope this article gives you some useful ideas. There is no particularly profound theory here, only the practical work of running into a problem and finding a way to solve it.

If this article helped you, feel free to give the project a Star on GitHub. The public beta has already started, and you can join the experience right after installing it. Open-source projects always need more feedback and encouragement, and if you found this useful, it is worth helping more people discover it.


“Beautiful things or people do not have to belong to you. As long as they remain beautiful, it is enough to quietly appreciate that beauty.”

The same goes for a DESIGN.md editor. It does not need to be overly complex. If it helps you work efficiently, that is already enough.

Thank you for reading. If you found this article useful, please consider liking, bookmarking, and sharing it. This content was created with AI-assisted collaboration, and the final version was reviewed and approved by the author.

Design.md: A Solution for Consistent AI-Driven Frontend UI Design

Design.md: A Solution for Consistent AI-Driven Frontend UI Design

Section titled “Design.md: A Solution for Consistent AI-Driven Frontend UI Design”

In the era of AI-assisted frontend development, how can you keep AI-generated UIs consistent? This article shares our hands-on experience building a design gallery site based on awesome-design-md, along with how to create a structured design.md to guide AI toward standardized UI design.

Anyone who has used AI to write frontend code has probably had a similar experience: ask AI to generate the same page several times, and each result comes out in a different style. Sometimes corners are rounded, sometimes they are sharp. Sometimes spacing is 8px, and other times it becomes 16px. Even the same button can look different across different conversations.

This is not an isolated issue. As AI-assisted development becomes more common, the lack of consistency in AI-generated frontend UI has become a widespread problem. Different AI assistants, different prompts, and even the same assistant across different conversations can produce dramatically different interface designs. That creates a huge maintenance cost during product iteration.

The root cause is actually simple: there is no authoritative design reference document. Traditional CSS stylesheets can tell developers “how to implement” something, but they cannot fully communicate “why it is designed this way” or “which design pattern should be used in which scenario.” For AI, a clear and structured description is even more important for understanding design conventions.

At the same time, the open-source community already offers some excellent resources. The VoltAgent/awesome-design-md project collects design system documentation from many well-known companies. Each directory contains a README.md, a DESIGN.md, and preview HTML. However, all of that is scattered across the upstream repository, making it hard to browse and compare quickly.

So, can we consolidate those resources into an easy-to-browse design gallery, while also distilling a structured design.md for AI to use?

The answer is yes. Next, let me walk through our approach.

The solution shared in this article comes from our hands-on experience in the HagiCode project. HagiCode is an AI-assisted development platform, and during development we ran into the same problem of inconsistent AI-generated UIs. To solve it, we built a design gallery site and created a standardized design.md. This article is a summary of that solution.

GitHub - HagiCode-org/site

First, take a look at the final homepage. It brings together the design gallery entry point, the site repository, the upstream repository, and background information about HagiCode in a single interface, making it easy for the team to establish a shared context before diving into specific entries.

Awesome Design MD Gallery homepage overview

Before writing code, let us break down the technical challenges behind this problem.

Source Content Management: How Do You Unify Scattered Design Resources?

Section titled “Source Content Management: How Do You Unify Scattered Design Resources?”

The upstream awesome-design-md repository contains a large number of design documents, but we needed a way to bring them into our own project.

Solution: use git submodule

awesome-design-md-site
└── vendor/awesome-design-md # Upstream resources (git submodule)

This gives us several benefits:

  • Version control: we can pin a specific upstream version
  • Offline builds: no need to request external APIs during the build
  • Content review: specific changes are visible in PRs

Data Normalization: How Do You Standardize Different Document Structures?

Section titled “Data Normalization: How Do You Standardize Different Document Structures?”

Different companies structure their design documents differently. Some are missing preview files, and some use inconsistent naming. We need to normalize them during the build process.

Solution: scan and generate normalized entries at build time

The core module is awesomeDesignCatalog.ts, responsible for:

  1. Scanning the vendor/awesome-design-md/design-md/* directory
  2. Validating whether each entry contains the required files (README.md, DESIGN.md, and at least one preview file)
  3. Extracting and rendering Markdown content into HTML
  4. Generating normalized entry data
src/lib/content/awesomeDesignCatalog.ts
export interface DesignEntry {
slug: string;
title: string;
summary: string;
readmeHtml: string;
designHtml: string;
previewLight?: string;
previewDark?: string;
searchText: string;
}
export async function scanSourceEntries() {
// Scan vendor/awesome-design-md/design-md/*
// Validate file completeness
// Generate normalized entries
}
export async function normalizeDesignEntry(dir: string) {
// Extract README.md and DESIGN.md
// Parse preview files
// Render Markdown to HTML
}

Static Site Architecture: How Do You Provide Dynamic Search While Staying Fully Static?

Section titled “Static Site Architecture: How Do You Provide Dynamic Search While Staying Fully Static?”

Since this is a design gallery, search is a must-have. But Astro is a static site generator, so how do you implement real-time search?

Solution: React island + URL query parameter sync

src/components/gallery/SearchToolbar.tsx
export function SearchToolbar() {
const [query, setQuery] = useState('');
// Sync with the URL
useEffect(() => {
const params = new URLSearchParams(window.location.search);
setQuery(params.get('q') || '');
}, []);
// Filter in real time
const filtered = entries.filter(entry =>
entry.searchText.includes(query)
);
return <input value={query} onChange={e => {
setQuery(e.target.value);
updateURL(e.target.value);
}} />;
}

The advantage of this approach is that it keeps the deployability of a static site intact, meaning it can be deployed to any static hosting service, while still delivering an instant filtering experience.

Design Documentation: How Do You Help AI Understand and Follow Design Standards?

Section titled “Design Documentation: How Do You Help AI Understand and Follow Design Standards?”

This is the core of the entire solution. We need to create a structured design.md that AI can understand and apply.

Solution: borrow the structure of ClickHouse DESIGN.md

ClickHouse’s DESIGN.md is an excellent reference. It includes:

  • Visual Theme & Atmosphere
  • Color Palette & Roles
  • Typography Rules
  • Component Stylings
  • Layout Principles
  • Depth & Elevation
  • Do’s and Don’ts
  • Responsive Behavior
  • Agent Prompt Guide

Our approach is: reuse the structure, rewrite the content. We keep the section structure of ClickHouse DESIGN.md, but replace the content with the actual design tokens and component conventions used in our own project.

Based on the analysis above, our solution consists of four core modules.

This is the foundation of the whole system, responsible for extracting and normalizing content from upstream resources.

src/lib/content/awesomeDesignCatalog.ts
export async function scanSourceEntries(): Promise<DesignEntry[]> {
const designDir = 'vendor/awesome-design-md/design-md';
const entries: DesignEntry[] = [];
for (const dir of await fs.readdir(designDir)) {
const entryPath = path.join(designDir, dir);
if (await isValidDesignEntry(entryPath)) {
const entry = await normalizeDesignEntry(entryPath);
entries.push(entry);
}
}
return entries;
}
async function isValidDesignEntry(dir: string): Promise<boolean> {
const requiredFiles = ['README.md', 'DESIGN.md'];
for (const file of requiredFiles) {
if (!(await fileExists(path.join(dir, file)))) {
return false;
}
}
return true;
}

The gallery interface includes three main parts:

Homepage: displays a card grid of all design entries, and each card includes:

  • Design entry title and summary
  • Preview image, if available
  • Quick-search highlighting

Detail page: aggregates the full information for a single design entry:

  • README document
  • DESIGN document
  • Preview with light/dark theme switching
  • Navigation to adjacent entries

Navigation: supports returning to the gallery and browsing adjacent entries

The homepage gallery uses a high-density card layout, flattening design.md entries from different sources into a unified visual framework so that teams can quickly compare brand styles, button patterns, and typographic rhythm.

Awesome Design MD Gallery design card grid

After opening a specific entry, the detail page places the design summary and live preview on the same page, reducing the cost of switching back and forth among documentation, previews, and source code.

Awesome Design MD Gallery design detail preview page

The search feature is based on client-side filtering, with state preserved through URL query parameters:

src/components/gallery/SearchToolbar.tsx
function SearchToolbar({ entries }: { entries: DesignEntry[] }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState(entries);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const initialQuery = params.get('q') || '';
setQuery(initialQuery);
filterEntries(initialQuery);
}, []);
const filterEntries = (searchQuery: string) => {
const filtered = entries.filter(entry =>
entry.searchText.toLowerCase().includes(searchQuery.toLowerCase())
);
setResults(filtered);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value);
filterEntries(value);
// Update the URL without triggering a page refresh
const newUrl = value
? `${window.location.pathname}?q=${encodeURIComponent(value)}`
: window.location.pathname;
window.history.replaceState({}, '', newUrl);
};
return (
<div className="search-toolbar">
<input
type="text"
value={query}
onChange={handleChange}
placeholder="Search design entries..."
/>
<span className="result-count">{results.length} results</span>
</div>
);
}

This is the core deliverable of the whole solution. We create a design.md in the project root with the following structure:

In addition to the raw design.md content consumed by AI, we also place both the README and DESIGN documents into the same reading interface, making it easier for people to proofread, copy snippets, and compare them against the preview results.

Awesome Design MD Gallery README and DESIGN document page

# Design Reference for [Project Name]
## 1. Visual Theme & Atmosphere
- Overall style description
- Design philosophy and principles
## 2. Color Palette & Roles
- Primary and supporting colors
- Semantic colors (`success`, `warning`, `error`)
- CSS variable definitions
## 3. Typography Rules
- Font families
- Type scale (`h1-h6`, `body`, `small`)
- Line height and font weight
## 4. Component Stylings
- Button style conventions
- Form component styles
- Card and container styles
## 5. Layout Principles
- Spacing system
- Grid and breakpoints
- Alignment principles
## 6. Depth & Elevation
- Shadow levels
- `z-index` conventions
## 7. Do's and Don'ts
- Common mistakes and correct approaches
## 8. Responsive Behavior
- Breakpoint definitions
- Responsive adaptation rules
## 9. Agent Prompt Guide
- How to use this document in AI prompts
- Example prompt templates

Now that we have covered the solution, how do you actually implement it?

Step 1: Initialize the submodule

Terminal window
# Add the upstream repository as a submodule
git submodule add https://github.com/VoltAgent/awesome-design-md.git vendor/awesome-design-md
# Initialize and update the submodule
git submodule update --init --recursive

Step 2: Create the content pipeline

Implement awesomeDesignCatalog.ts, including:

  • File scanning and validation logic
  • Markdown rendering using Astro’s built-in renderer
  • Entry data extraction

Step 3: Build the gallery UI

Use Astro + React Islands to create:

  • Homepage gallery layout (card grid)
  • Design card components
  • Search toolbar
  • Detail page layout

Step 4: Write the design document

Based on the structure of ClickHouse DESIGN.md, fill in the actual design tokens from your own project. Update README.md and add a link to design.md.

Security: Markdown rendering requires filtering unsafe HTML. Astro’s built-in renderer filters script tags by default, but you still need to watch for XSS risks.

Performance: A large number of iframe previews may affect first-paint performance. It is recommended to use loading="lazy" to lazy-load preview content.

Maintainability: design.md needs to stay in sync with the code implementation. It is recommended to add CI checks to ensure that CSS variables remain consistent between documentation and code.

Accessibility: Make sure color contrast meets the WCAG AA standard (at least 4.5:1).

After creating design.md, how do you get AI to actually use it? Here are a few practical tips:

Tip 1: Reference it explicitly in the prompt

Please refer to the design.md file in the project root and use the design conventions defined there to implement the following components:
- Buttons: use the primary color with an 8px border radius
- Cards: use the elevation-2 shadow level

Tip 2: Require AI to reference specific CSS variables

Implement a navigation bar with the following requirements:
- Use --color-bg-primary for the background color
- Use --color-border-subtle for borders
- Use --text-color-primary for text

Tip 3: Include design.md content in the system prompt

If your AI tool supports custom system prompts, you can add the core content of design.md directly to it.

Content pipeline testing:

  • Missing-file scenarios (missing README.md or DESIGN.md)
  • Format error scenarios (Markdown parsing failure)
  • Empty-directory scenarios

Search feature testing:

  • Empty result handling
  • Special characters such as Chinese and emoji
  • URL sync verification

UI component testing:

  • Light/dark theme switching
  • Responsive layout
  • Preview loading states
Terminal window
# 1. Update the submodule to the latest version
git submodule update --remote
# 2. Rebuild the site
npm run build
# 3. Deploy static assets
npm run deploy

It is recommended to automate submodule updates plus build and deployment, so that CI can be triggered automatically whenever the upstream repository is updated.

The inconsistency in AI-generated UIs that HagiCode encountered during development was, at its core, caused by the lack of a structured design reference document. By building a design gallery site and creating a standardized design.md, we successfully solved this problem.

The core value of this solution lies in:

  • Unified resources: consolidating scattered design system documentation
  • Structured standards: expressing design conventions in a format AI can understand
  • Continuous maintenance: keeping content up to date through git submodule

If you are also using AI for frontend development, this approach is worth trying. Creating a structured design.md not only improves the consistency of AI-generated code, but also helps your team maintain unified design standards internally.


If this article helped you:

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

Why Use Skillsbase to Maintain Your Own Skills Collection Repository

Why Use Skillsbase to Maintain Your Own Skills Collection Repository

Section titled “Why Use Skillsbase to Maintain Your Own Skills Collection Repository”

It is kind of funny when you think about it: the era of AI programming has arrived, and the Agent Skills we keep on hand are becoming more and more numerous. But along with that comes more and more hassle. This article is about how we used skillsbase to solve those problems.

In the age of AI programming, developers need to maintain an increasing number of Agent Skills - reusable instruction sets that extend the capabilities of coding assistants such as Claude Code, OpenCode, and Cursor. However, as the number of skills grows, a practical problem gradually emerges:

It is not exactly a major problem, but once you have too many things, managing them becomes troublesome.

Skills are scattered across different locations, making management costly

Section titled “Skills are scattered across different locations, making management costly”
  • Local skills are scattered in multiple places: ~/.agents/skills/, ~/.claude/skills/, ~/.codex/skills/.system/, and so on
  • Different locations may have naming conflicts, for example skill-creator existing in both the user directory and the system directory
  • There is no unified management entry point, which makes backup and migration difficult

This part is genuinely annoying. Sometimes you do not even know where a certain skill actually is. It feels like losing something and then struggling to find it.

Lack of a standardized maintenance workflow

Section titled “Lack of a standardized maintenance workflow”
  • Manually copying skills is error-prone and makes it difficult to trace their origins
  • Without a unified validation mechanism, there is no guarantee that the skill repository remains complete
  • During team collaboration, synchronizing and sharing a skill collection is difficult

Manual work is always prone to mistakes. Human memory is limited, after all. Who can remember where every single thing came from?

Failing to meet reproducibility requirements

Section titled “Failing to meet reproducibility requirements”
  • When switching development machines, all skills need to be configured again
  • In CI/CD environments, the skill repository cannot be validated and synchronized automatically

Changing to a different computer means doing everything all over again. It feels, in a way, just like moving house - troublesome every single time. You have to adapt to the new environment and reconfigure everything again.

To address these pain points, we tried many different approaches: from manual copying to scripted automation, from directly managing directories to globally installing and then recovering files. Each approach had its own flaws. Some could not guarantee consistency, some polluted the environment, and some were hard to use in CI.

We definitely took quite a few detours.

In the end, we found a more elegant solution: skillsbase. The core idea behind this approach is to install and validate locally first, then convert the structure and write it into the repository, and finally uninstall the temporary files. This ensures that the repository contents match the actual installation result while avoiding pollution of the global environment.

It sounds simple when you put it that way, but we only figured it out after stepping into quite a few pitfalls.

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

HagiCode is an AI coding assistant project. During development, we need to maintain a large number of Agent Skills to extend various coding capabilities. These real-world needs are exactly what pushed us to build the skillsbase toolset for standardized management of skill repositories.

This was not invented out of thin air. We were pushed into it by real needs. Once the number of skills grows, management naturally becomes necessary. When problems appear during management, solutions become necessary too. Step by step, that is how we got here.

If you are interested in HagiCode, you can visit the official website to learn more or check the source code on GitHub.

To build a maintainable skills collection repository, the following core problems need to be solved:

  1. Unified namespace conflicts: when multiple sources contain skills with the same name, how do we avoid overwriting them?
  2. Source traceability: how do we record the source of each skill for future updates and audits?
  3. Synchronization and validation: how do we ensure that repository contents stay consistent with the actual installation results?
  4. Automation integration: how do we integrate with CI/CD workflows to enable automatic synchronization and validation?

These problems may look simple, but every single one of them is a headache. Then again, what worthwhile work is ever easy?

Option 1: Copy directories directly

Pros: simple to implement Cons: cannot guarantee consistency with the actual installation result of the skills CLI

We did think about this approach. Later, however, we realized that the CLI may apply some preprocessing logic during installation. Direct copying skips that step. As a result, what you copy is not the same as what is actually installed, and that becomes a problem.

Option 2: Install globally and then recover

Pros: the installation process can be validated Cons: pollutes the execution environment, and it is hard to keep CI and local results consistent

This approach is even worse. A global installation pollutes the environment. More importantly, it is difficult to keep the CI environment consistent with the local environment, which leads to the classic “works on my machine, fails in CI” problem. Anyone who has dealt with that knows how painful it is.

Option 3: Local install -> convert -> uninstall (final solution)

This is the approach adopted by skillsbase:

  • First install skills into a temporary location with npx skills
  • Convert the directory structure and add source metadata
  • Write the result into the target repository
  • Finally uninstall the temporary files

This approach ensures that repository contents are consistent with the actual installation results seen by consumers, avoids polluting the global environment, standardizes the conversion process, and supports idempotent operations.

This solution was not obvious from the beginning either. We simply learned through enough trial and error what works and what does not.

Decision ItemChoiceReason
RuntimeNode.js ESMNo build step required; .mjs is enough to orchestrate the file system
Configuration formatYAML (sources.yaml)Highly readable and suitable for manual maintenance
Naming strategyNamespace prefixUser skills keep their original names, while system skills receive the system- prefix
Workflowadd updates the manifest -> sync executes synchronizationA single synchronization engine avoids implementing the same rules twice
File managementManaged file markersAdd a comment header to support safe overwrites

These decisions all come down to one goal: making things simple. Simplicity wins in the end.

The skillsbase CLI provides four core commands:

skillsbase
├── init # Initialize repository structure
├── sync # Synchronize skill content
├── add # Add new skills
└── github_action # Generate GitHub Actions configuration

There are not many commands, but they are enough. A tool only needs to be useful.

┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ init │───▶│ add │───▶│ sync │───▶│github_action│
│ initialize │ │ add source │ │ sync content│ │ generate CI │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘

Take it one step at a time. No need to rush.

sources.yaml -> parse sources -> npx skills install -> convert structure -> write to skills/ -> uninstall temporary files
.skill-source.json (source metadata)

This workflow is fairly clear. At least when I look at it, I can understand what each step is doing.

repos/skillsbase/
├── sources.yaml # Source manifest (single source of truth)
├── skills/ # Skills directory
│ ├── frontend-design/ # User skill
│ ├── skill-creator/ # User skill
│ └── system-skill-creator/ # System skill (with prefix)
├── scripts/
│ ├── sync-skills.mjs # Synchronization script
│ └── validate-skills.mjs # Validation script
├── docs/
│ └── maintainer-workflow.md # Maintainer documentation
└── .github/
├── workflows/
│ └── skills-sync.yml # CI workflow
└── actions/
└── skillsbase-sync/
└── action.yml # Reusable Action

There are quite a few files, but that is fine. Once the structure is organized clearly, maintenance becomes much easier.

Terminal window
# 1. Create an empty repository
mkdir repos/myskills && cd repos/myskills
git init
# 2. Initialize it with skillsbase
npx skillsbase init
# Output:
# [1/4] create manifest ................. done
# [2/4] create scripts .................. done
# [3/4] create docs ..................... done
# [4/4] create github workflow .......... done
#
# next: skillsbase add <skill-name>

This step generates a lot of files, but there is no need to worry - they are all generated automatically. After that, you can start adding skills.

Terminal window
# Add a single skill (this automatically triggers synchronization)
npx skillsbase add frontend-design --source vercel-labs/agent-skills
# Add from a local source
npx skillsbase add documentation-writer --source /home/user/.agents/skills
# Output:
# source: first-party ......... updated
# target: skills/frontend-design ... synced
# status: 1 skill added, 0 removed

Adding a skill is very simple. One command is enough. Sometimes, though, you may hit unexpected issues such as poor network conditions or permission problems. Those are manageable - just take them one at a time.

Terminal window
# Perform synchronization (reconcile all sources)
npx skillsbase sync
# Only check for drift (do not modify files)
npx skillsbase sync --check
# Allow missing sources (CI scenario)
npx skillsbase sync --allow-missing-sources

During synchronization, the system checks every source defined in sources.yaml and reconciles them with the contents under the skills/ directory. If differences exist, it updates them; if there are no differences, it skips them. This prevents the “configuration changed but files did not” problem.

Terminal window
# Generate workflow
npx skillsbase github_action --kind workflow
# Generate action
npx skillsbase github_action --kind action
# Generate everything
npx skillsbase github_action --kind all

The CI configuration is generated automatically as well. You still need to adjust some details yourself, such as trigger conditions and runtime environments, but that is not difficult.

# Skills root directory configuration
skillsRoot: skills/
metadataFile: .skill-source.json
# Source definitions
sources:
# First-party: local user skills
first-party:
type: local
path: /home/user/.agents/skills
naming: original # Keep original name
includes:
- documentation-writer
- frontend-design
- skill-creator
# System: skills provided by the system
system:
type: local
path: /home/user/.codex/skills/.system
naming: prefix-system # Add system- prefix
includes:
- imagegen
- openai-docs
- skill-creator # Becomes system-skill-creator
# Remote: third-party repository
vercel:
type: remote
url: vercel-labs/agent-skills
naming: original
includes:
- web-design-guidelines

This configuration file is the core of the entire system. All sources are defined here. Change this file, and the next synchronization will apply the new state. In that sense, it is truly a “single source of truth.”

{
"source": "first-party",
"originalPath": "/home/user/.agents/skills/documentation-writer",
"originalName": "documentation-writer",
"targetName": "documentation-writer",
"syncedAt": "2026-04-07T00:00:00.000Z",
"version": "unknown"
}

Every skill directory contains this file, recording its source information. That way, when something goes wrong later, you can quickly locate where it came from and when it was synchronized.

Terminal window
# Validate repository structure
node scripts/validate-skills.mjs
# Validate with the skills CLI
npx skills add . --list
# Check for updates
npx skills check

Validation is one of those things that can feel both important and optional. Still, for the sake of safety, it never hurts to run it from time to time. After all, you never know when something unexpected might happen.

.github/workflows/skills-sync.yml
name: Skills Sync
on:
push:
paths:
- 'sources.yaml'
- 'skills/**'
workflow_dispatch:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Validate repository
run: |
npx skills add . --list
node scripts/validate-skills.mjs
- name: Sync check
run: npx skillsbase sync --check

Once CI integration is in place, every change to sources.yaml or the skills/ directory automatically triggers validation. That prevents the situation where changes were made locally but synchronization was forgotten.

  1. Handle naming conflicts: add the system- prefix to system skills consistently. This keeps every skill available while avoiding naming conflicts.
  2. Idempotent operations: all commands support repeated execution, and running sync multiple times does not produce side effects. This is especially important in CI.
  3. Managed files: generated files include the # Managed by skillsbase CLI comment, making them easy to identify and manage. These files can be safely overwritten, and manual modifications are not preserved.
  4. Non-interactive mode: CI environments use deterministic behavior by default, so interactive prompts do not interrupt execution. All configuration is declared through sources.yaml.
  5. Source traceability: every skill has a .skill-source.json file recording its source information, making troubleshooting much faster.
Terminal window
# Team members install the shared skills repository
npx skills add your-org/myskills -g --all
# Clone locally and validate
git clone https://github.com/your-org/myskills.git
cd myskills
npx skills add . --list

By managing the skills repository with Git, team members can easily synchronize their skill collection and ensure that everyone uses the same versions of tools and configuration.

This is especially useful in team collaboration. You no longer run into situations where “it works for me but not for you.” Once the environment is unified, half the problems disappear.

The core value of using skillsbase to maintain a skills collection repository lies in the following:

  • Security: source validation, conflict detection, and managed file protection
  • Maintainability: a unified entry point, idempotent operations, and configuration-as-documentation
  • Standardization: a unified directory structure, naming conventions, and metadata format
  • Automation: CI/CD integration, automatic synchronization, and automatic validation

With this approach, developers can manage their own Agent Skills the same way they manage npm packages, building a reproducible, shareable, and maintainable skills repository system.

The tools and workflow shared in this article are exactly what we refined through real mistakes and real optimization while building HagiCode. If you find this approach valuable, that is a good sign that our engineering direction is the right one - and that HagiCode itself is worth your attention as well.

After all, good tools deserve to be used by more people.

If this article helped you:


This article was first published on the HagiCode Blog.

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

How to Reproduce Projects in the AI Era: Vault, a Cross-Project Persistent Storage System

How to Reproduce Projects in the AI Era: Vault, a Cross-Project Persistent Storage System

Section titled “How to Reproduce Projects in the AI Era: Vault, a Cross-Project Persistent Storage System”

In the era of AI-assisted development, how can we help AI assistants better understand our learning resources? The HagiCode project built the Vault system as a unified knowledge storage abstraction layer that AI can understand, greatly improving the efficiency of learning through project reproduction.

In the AI era, the way developers learn new technologies and architectures is changing profoundly. “Reproducing projects” - that is, deeply studying and learning from the code, architecture, and design patterns of excellent open source projects - has become an efficient way to learn. Compared with traditional methods like reading books or watching videos, directly reading and running high-quality open source projects helps you understand real-world engineering practices much faster.

Still, this learning method comes with quite a few challenges.

Learning materials are too scattered. Your notes may live in Obsidian, code repositories may be scattered across different folders, and your AI assistant’s conversation history becomes yet another isolated data island. When you want AI to help analyze a project, you have to manually copy code snippets and organize context, which is rather tedious.

What is even more troublesome is the broken context. AI assistants cannot directly access your local learning resources, so you have to provide background information again in every conversation. On top of that, reproduced code repositories update quickly, manual syncing is error-prone, and knowledge is hard to share across multiple learning projects.

At the root, all of these problems come from “data islands.” If there were a unified storage abstraction layer that allowed AI assistants to understand and access all your learning resources, the problem would be solved neatly.

The Vault system shared in this article is exactly the solution we developed while building HagiCode. HagiCode is an AI coding assistant project, and in our daily development work we often need to study and refer to many different open source projects. To help AI assistants better understand these learning resources, we designed Vault, a cross-project persistent storage system.

This solution has already been validated in HagiCode in real use. If you are facing similar knowledge management challenges, I hope these experiences can offer some inspiration. After all, once you’ve fallen into a few pits yourself, you should leave something behind for the next person.

The core idea of the Vault system is simple: create a unified knowledge storage abstraction layer that AI can understand. From an implementation perspective, the system has several key characteristics.

The system supports four vault types, each corresponding to a different usage scenario:

// folder: general-purpose folder type
export const DEFAULT_VAULT_TYPE = 'folder';
// coderef: a type specifically for reproduced code projects
export const CODEREF_VAULT_TYPE = 'coderef';
// obsidian: integrated with Obsidian note-taking software
export const OBSIDIAN_VAULT_TYPE = 'obsidian';
// system-managed: vault automatically managed by the system
export const SYSTEM_MANAGED_VAULT_TYPE = 'system-managed';

Among them, the coderef type is the most commonly used in HagiCode. It is specifically designed for reproduced code projects, providing a standardized directory structure and AI-readable metadata descriptions.

The Vault registry is stored persistently in JSON format, ensuring that the configuration remains available after the application restarts:

public class VaultRegistryStore : IVaultRegistryStore
{
private readonly string _registryFilePath;
public VaultRegistryStore(IConfiguration configuration, ILogger<VaultRegistryStore> logger)
{
var dataDir = configuration["DataDir"] ?? "./data";
var absoluteDataDir = Path.IsPathRooted(dataDir)
? dataDir
: Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), dataDir));
_registryFilePath = Path.Combine(absoluteDataDir, "personal-data", "vaults", "registry.json");
}
}

The advantage of this design is that it is simple and reliable. JSON is human-readable, which makes debugging and manual editing easier; filesystem storage avoids the complexity of a database and reduces system dependencies. After all, sometimes the simplest option really is the best one.

Most importantly, the system can automatically inject vault information into the context of AI proposals:

export function buildTargetVaultsText(
vaults: VaultForText[],
template: VaultPromptTemplate = DEFAULT_VAULT_PROMPT_TEMPLATE,
): string {
const readOnlyVaults = vaults.filter((vault) => vault.accessType === 'read');
const editableVaults = vaults.filter((vault) => vault.accessType === 'write');
if (readOnlyVaults.length === 0 && editableVaults.length === 0) {
return '';
}
const sections = [
buildVaultSection(readOnlyVaults, template.reference),
buildVaultSection(editableVaults, template.editable),
].filter(Boolean);
return `\n\n### ${template.heading}\n\n${sections.join('\n')}`;
}

This enables an important capability: AI assistants can automatically understand the available learning resources without users manually providing context. You could say that counts as a kind of tacit understanding.

The standardized structure of CodeRef Vault

Section titled “The standardized structure of CodeRef Vault”

For the coderef type of vault, HagiCode provides a standardized directory structure:

my-coderef-vault/
├── index.yaml # vault metadata description
├── AGENTS.md # operating guide for AI assistants
├── docs/ # stores study notes and documents
└── repos/ # manages reproduced code repositories through Git submodules

When creating a vault, the system automatically initializes this structure:

private async Task EnsureCodeRefStructureAsync(
string vaultName,
string physicalPath,
ICollection<VaultBootstrapDiagnosticDto> diagnostics,
CancellationToken cancellationToken)
{
Directory.CreateDirectory(physicalPath);
var indexPath = Path.Combine(physicalPath, CodeRefIndexFileName);
var docsPath = Path.Combine(physicalPath, CodeRefDocsDirectoryName);
var reposPath = Path.Combine(physicalPath, CodeRefReposDirectoryName);
// Create the standard directory structure
if (!Directory.Exists(docsPath))
{
Directory.CreateDirectory(docsPath);
}
if (!Directory.Exists(reposPath))
{
Directory.CreateDirectory(reposPath);
}
// Create the AGENTS.md guide
await EnsureCodeRefAgentsDocumentAsync(physicalPath, cancellationToken);
// Create the index.yaml metadata
await WriteCodeRefIndexDocumentAsync(indexPath, mergedDocument, cancellationToken);
}

This structure is carefully designed as well:

  • docs/ stores your study notes, where you can record your understanding of the code, architecture analysis, lessons learned, and so on in Markdown
  • repos/ manages reproduced repositories through Git submodules instead of copying code directly, which keeps the code in sync and saves space
  • index.yaml contains the vault metadata so AI assistants can quickly understand the purpose and contents of the vault
  • AGENTS.md is a guide written specifically for AI assistants, explaining how to handle the contents of the vault

Organized this way, perhaps AI can understand what you have in mind a little more easily.

Automatic initialization for system-managed vaults

Section titled “Automatic initialization for system-managed vaults”

In addition to manually created vaults, HagiCode also supports system-managed vaults:

public async Task<IReadOnlyList<VaultRegistryEntry>> EnsureAllSystemManagedVaultsAsync(
CancellationToken cancellationToken = default)
{
var definitions = GetAllResolvedDefinitions();
var entries = new List<VaultRegistryEntry>(definitions.Count);
foreach (var definition in definitions)
{
entries.Add(await EnsureResolvedSystemManagedVaultAsync(definition, cancellationToken));
}
return entries;
}

The system automatically creates and manages the following vaults:

  • hagiprojectdata: project data storage used to save project configuration and state
  • personaldata: personal data storage used to save user preferences
  • hbsprompt: a prompt template library used to manage commonly used AI prompts

These vaults are initialized automatically when the system starts, so users do not need to configure them manually. Some things are simply better left to the system instead of humans worrying about them.

An important part of the design is access control. The system divides vaults into two access types:

export interface VaultForText {
id: string;
name: string;
type: string;
physicalPath: string;
accessType: 'read' | 'write'; // Key: distinguish read-only from editable
}
  • reference (read-only): AI is only used for analysis and understanding and cannot modify content. Suitable for referenced open source projects, documents, and similar materials
  • editable (editable): AI can modify content as needed for the task. Suitable for your notes, drafts, and similar materials

This distinction matters. It tells AI which content is “read-only reference” and which content is “safe to edit,” reducing the risk of accidental changes. After all, nobody wants their hard work to disappear because of an unintended edit.

Now that we’ve covered the ideas, let’s look at how it works in practice.

Here is a complete frontend call example:

const createCodeRefVault = async () => {
const response = await VaultService.postApiVaults({
requestBody: {
name: "React Learning Vault",
type: "coderef",
physicalPath: "/Users/developer/vaults/react-learning",
gitUrl: "https://github.com/facebook/react.git"
}
});
// The system will automatically:
// 1. Clone the React repository into vault/repos/react
// 2. Create the docs/ directory for notes
// 3. Generate the index.yaml metadata
// 4. Create the AGENTS.md guide file
return response;
};

This API call completes a series of actions: creating the directory structure, initializing Git submodules, generating metadata files, and more. You only need to provide the basic information and let the system handle the rest. It is honestly a fairly worry-free approach.

After creating the vault, you can reference it in an AI proposal:

const proposal = composeProposalChiefComplaint({
chiefComplaint: "Help me analyze React's concurrent rendering mechanism",
repositories: [
{ id: "react", gitUrl: "https://github.com/facebook/react.git" }
],
vaults: [
{
id: "react-learning",
name: "React Learning Vault",
type: "coderef",
physicalPath: "/vaults/react-learning",
accessType: "read" // AI can only read, not modify
}
],
quickRequestText: "Focus on the Fiber architecture and scheduler implementation"
});

The system automatically injects vault information into the AI context, letting AI know which learning resources are available. When AI can understand what you have in mind, that kind of tacit understanding is hard to come by.

While using the Vault system, we have summarized a few lessons learned.

The system strictly validates paths to prevent path traversal attacks:

private static string ResolveFilePath(string vaultRoot, string relativePath)
{
var rootPath = EnsureTrailingSeparator(Path.GetFullPath(vaultRoot));
var combinedPath = Path.GetFullPath(Path.Combine(rootPath, relativePath));
if (!combinedPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException(VaultRelativePathTraversalCode,
"Vault file paths must stay inside the registered vault root.");
}
return combinedPath;
}

This is important. If you customize a vault path, make sure it stays within the allowed range, otherwise the system will reject the operation. You really cannot overemphasize security.

CodeRef Vault recommends Git submodules instead of directly copying code:

private static string BuildCodeRefAgentsContent()
{
return """
# CodeRef Vault Guide
Repositories under `repos/` should be maintained through Git submodules
rather than copied directly into the vault root.
Keep this structure stable so assistants and tools can understand the vault quickly.
""" + Environment.NewLine;
}

This brings several advantages: keeping code synchronized with upstream, saving disk space, and making it easier to manage multiple versions of the code. After all, who wants to download the same thing again and again?

To prevent performance problems, the system limits file size and type:

private const int FileEnumerationLimit = 500;
private const int PreviewByteLimit = 256 * 1024; // 256KB

If your vault contains a large number of files or very large files, preview performance may be affected. In that case, you can consider processing files in batches or using specialized search tools. Sometimes when something gets too large, it becomes harder to handle, not easier.

When creating a vault, the system returns diagnostic information to help with debugging:

List<VaultBootstrapDiagnosticDto> bootstrapDiagnostics = [];
if (IsCodeRefVaultType(normalizedType))
{
bootstrapDiagnostics = await EnsureCodeRefBootstrapAsync(
normalizedName,
normalizedPhysicalPath,
normalizedGitUrl,
cancellationToken);
}

If creation fails, you can inspect the diagnostic information to understand the specific cause. When something goes wrong, checking the diagnostics is often the most direct way forward.

Through a unified storage abstraction layer, the Vault system solves several core pain points of reproducing projects in the AI era:

  • Centralized knowledge management: all learning resources are gathered in one place instead of scattered everywhere
  • Automatic AI context injection: AI assistants can automatically understand the available learning resources without manual context setup
  • Cross-project knowledge reuse: knowledge can be shared and reused across multiple learning projects
  • Standardized directory structure: a consistent directory layout lowers the learning curve

This solution has already been validated in the HagiCode project. If you are also building tools related to AI-assisted development, or facing similar knowledge management problems, I hope these experiences can serve as a useful reference.

In truth, the value of a technical solution does not lie in how complicated it is, but in whether it solves real problems. The core idea of the Vault system is very simple: build a unified knowledge storage layer that AI can understand. Yet it is precisely this simple abstraction that improved our development efficiency quite a bit.

Sometimes the simple approach really is the best one. After all, complicated things often hide even more pitfalls…


If this article helped you, feel free to give the project a Star on GitHub, or visit the official website to learn more about HagiCode. The public beta has already started, and you can experience the full AI coding assistant features as soon as you install it.

Maybe you should give it a try as well…

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

Progressive Disclosure: Improving Human-Computer Interaction in AI Products with the Less Is More Philosophy

Progressive Disclosure: Improving Human-Computer Interaction in AI Products with the “Less Is More” Philosophy

Section titled “Progressive Disclosure: Improving Human-Computer Interaction in AI Products with the “Less Is More” Philosophy”

In AI product design, the quality of user input often determines the quality of the output. This article shares a “progressive disclosure” interaction approach we practiced in the HagiCode project. Through step-by-step guidance, intelligent completion, and instant feedback, it turns users’ brief and vague inputs into structured technical proposals, significantly improving human-computer interaction efficiency.

Anyone building AI products has probably seen this situation: a user opens your app and enthusiastically types a one-line request, only for the AI to return something completely off target. It is not that the AI is not smart enough. The user simply did not provide enough information. Mind-reading is hard for anyone.

This issue became especially obvious while we were building HagiCode. HagiCode is an AI-driven coding assistant where users describe requirements in natural language to create technical proposals and sessions. In actual use, we found that user input often has these problems:

  • Inconsistent input quality: some users type only a few words, such as “optimize login” or “fix bug”, without the necessary context
  • Inconsistent technical terminology: different users use different terms for the same thing; some say “frontend” while others say “FE”
  • Missing structured information: there is no project background, repository scope, or impact scope, even though these are critical details
  • Repeated problems: the same types of requests appear again and again, and each time they need to be explained from scratch

The direct result is predictable: the AI has a harder time understanding the request, proposal quality becomes unstable, and the user experience suffers. Users think, “This AI is not very good,” while we feel unfairly blamed. If you give me only one sentence, how am I supposed to guess what you really want?

In truth, this is understandable. Even people need time to understand one another, and machines are no exception.

To solve these pain points, we made a bold decision: introduce the design principle of “progressive disclosure” to improve human-computer interaction. The changes this brought were probably larger than you would imagine. To be honest, we did not expect it to be this effective at the time.

The approach shared in this article comes from our practical experience in the HagiCode project. HagiCode is an open-source AI coding assistant designed to help developers complete tasks such as code writing, technical proposal generation, and code review through natural language interaction. Project link: github.com/HagiCode-org/site.

We developed this progressive disclosure approach through multiple rounds of iteration and optimization during real product development. If you find it valuable, that at least suggests our engineering is doing something right. In that case, HagiCode itself may also be worth a look. Good tools are meant to be shared.

“Progressive disclosure” is a design principle from the field of HCI (human-computer interaction). Its core idea is simple: do not show users all information and options at once. Instead, reveal only what is necessary step by step, based on the user’s actions and needs.

This principle is especially suitable for AI products because AI interaction is naturally progressive. The user says a little, the AI understands a little, then the user adds more, and the AI understands more. It is very similar to how people communicate with each other: understanding usually develops gradually.

In HagiCode’s scenario, we applied progressive disclosure in four areas:

1. Description optimization mechanism: let AI help you say things more clearly

Section titled “1. Description optimization mechanism: let AI help you say things more clearly”

When a user enters a short description, we do not send it directly to the AI for interpretation. Instead, we first trigger a “description optimization” flow. The core of this flow is “structured output”: converting the user’s free text into a standard format. It is like stringing loose pearls into a necklace so everything becomes easier to understand.

The optimized description must include the following standard sections:

  • Background: the problem background and context
  • Analysis: technical analysis and reasoning
  • Solution: the solution and implementation steps
  • Practice: concrete code examples and notes

At the same time, we automatically generate a Markdown table showing information such as the target repository, paths, and edit permissions, making subsequent AI operations easier. A clear directory always makes things easier to find.

Here is the actual implementation:

// Core method in ProposalDescriptionMemoryService.cs
public async Task<string> OptimizeDescriptionAsync(
string title,
string description,
string locale = "zh-CN",
DescriptionOptimizationMemoryContext? memoryContext = null,
CancellationToken cancellationToken = default)
{
// Build query parameters
var queryContext = BuildQueryContext(title, description);
// Retrieve historical context
var memoryContext = await RetrieveHistoricalContextAsync(queryContext, cancellationToken);
// Generate a structured prompt
var prompt = await BuildOptimizationPromptAsync(
title,
description,
memoryContext,
cancellationToken);
// Call AI for optimization
return await _aiService.CompleteAsync(prompt, cancellationToken);
}

The key to this flow is “memory injection”. We inject historical context such as project conventions, similar cases, and negative patterns into the prompt, allowing the AI to reference past experience during optimization. Experience should not go to waste.

Notes:

  • Make sure the current input takes priority over historical memory, so explicitly specified user information is not overridden
  • HagIndex references must be treated as factual sources and must not be altered by historical cases
  • Low-confidence correction suggestions should not be injected as strong constraints

2. Voice input capability: speaking is more natural than typing

Section titled “2. Voice input capability: speaking is more natural than typing”

In addition to text input, we also support voice input. This is especially useful for describing complex requirements. Typing a technical request can take minutes, while saying it out loud may take only a few dozen seconds.

The key design focus for voice input is “state management”. Users must clearly know what state the system is currently in. We defined the following states:

  • Idle: the system is ready and recording can start
  • Waiting upstream: the system is connecting to the backend service
  • Recording: the user’s voice is being recorded
  • Processing: speech is being converted to text
  • Error: an error occurred and needs user attention

The frontend state model looks roughly like this:

interface VoiceInputState {
status: 'idle' | 'waiting-upstream' | 'recording' | 'processing' | 'error';
duration: number;
error?: string;
deletedSet: Set<string>; // Fingerprint set of deleted results
}
// State transition when recording starts
const handleVoiceInputStart = async () => {
// Enter waiting state first and show a loading animation
setState({ status: 'waiting-upstream' });
// Wait for backend readiness confirmation
const isReady = await waitForBackendReady();
if (!isReady) {
setState({ status: 'error', error: 'Backend service is not ready' });
return;
}
// Start recording
setState({ status: 'recording', startTime: Date.now() });
};
// Handle recognition results
const handleRecognitionResult = (result: RecognitionResult) => {
const fingerprint = normalizeFingerprint(result.text);
// Check whether it has already been deleted
if (state.deletedSet.has(fingerprint)) {
return; // Skip deleted content
}
// Merge the result into the text box
appendResult(result);
};

There is an important detail here: we use a “fingerprint set” to manage deletion synchronization. When speech recognition returns multiple results, users may delete some of them. We store the fingerprints of deleted content so that if the same content appears again later, it is skipped automatically. It is essentially a way to remember what the user has already rejected.

3. Prompt management system: externalize the AI’s “brain”

Section titled “3. Prompt management system: externalize the AI’s “brain””

HagiCode has a flexible prompt management system in which all prompts are stored as files:

prompts/
├── metadata/
│ ├── optimize-description.zh-CN.json
│ └── optimize-description.en-US.json
└── templates/
├── optimize-description.zh-CN.hbs
└── optimize-description.en-US.hbs

Each prompt consists of two parts:

  • Metadata file (.json): defines information such as the prompt scenario, version, and parameters
  • Template file (.hbs): the actual prompt content, written with Handlebars syntax

The metadata file format looks like this:

{
"scenario": "optimize-description",
"locale": "zh-CN",
"version": "1.0.0",
"syntax": "handlebars",
"syntaxVersion": "1.0",
"parameters": [
{
"name": "title",
"type": "string",
"required": true,
"description": "Proposal title"
},
{
"name": "description",
"type": "string",
"required": true,
"description": "Original description"
}
],
"author": "HagiCode Team",
"description": "Optimize the user's technical proposal description",
"lastModified": "2026-04-05",
"tags": ["optimization", "nlp"]
}

The template file uses Handlebars syntax and supports parameter injection:

You are a technical proposal expert.
<task>
Generate a structured technical proposal description based on the following information.
</task>
<input>
<title>{{title}}</title>
<description>{{description}}</description>
{{#if memoryContext}}
<memory_context>
{{memoryContext}}
</memory_context>
{{/if}}
</input>
<output_format>
## Background
[Describe the problem background and context, including project information, repository scope, and so on]
## Analysis
[Provide the technical analysis and reasoning process, and explain why this change is needed]
## Solution
[Provide the solution and implementation steps, listing the key code locations]
## Practice
[Provide concrete code examples and notes]
</output_format>

The benefits of this design are clear:

  • prompts can be version-controlled just like code
  • multiple languages are supported and can be switched automatically based on user preference
  • the parameterized design allows context to be injected dynamically
  • completeness can be validated at startup, avoiding runtime errors

If knowledge stays only in someone’s head, it is easy to lose. Recording it in a structured way from the beginning is much safer.

4. Progressive wizard: split complex tasks into small steps

Section titled “4. Progressive wizard: split complex tasks into small steps”

For complex tasks, such as first-time installation and configuration, we use a multi-step wizard design. Each step requests only the necessary information and provides clear progress indicators. Large tasks become much more manageable when handled one step at a time.

The wizard state model:

interface WizardState {
currentStep: number; // 0-3, corresponding to 4 steps
steps: WizardStep[];
canGoNext: boolean;
canGoBack: boolean;
isLoading: boolean;
error: string | null;
}
interface WizardStep {
id: number;
title: string;
description: string;
completed: boolean;
}
// Step navigation logic
const goToNextStep = () => {
if (wizardState.currentStep < wizardState.steps.length - 1) {
// Validate input for the current step
if (validateCurrentStep()) {
wizardState.currentStep++;
wizardState.steps[wizardState.currentStep - 1].completed = true;
}
}
};
const goToPreviousStep = () => {
if (wizardState.currentStep > 0) {
wizardState.currentStep--;
}
};

Each step has its own validation logic, and completed steps receive clear visual markers. Canceling opens a confirmation dialog to prevent users from losing progress through accidental actions.

Looking back at HagiCode’s progressive disclosure practice, we can summarize several core principles:

  1. Step-by-step guidance: break complex tasks into smaller steps and request only the necessary information at each stage
  2. Intelligent completion: use historical context and project knowledge to fill in information automatically
  3. Instant feedback: give every action clear visual feedback and status hints
  4. Fault-tolerance mechanisms: allow users to undo and reset so mistakes do not lead to irreversible loss
  5. Input diversity: support multiple input methods such as text and voice

In HagiCode, the practical result of this approach was clear: the average length of user input increased from fewer than 20 characters to structured descriptions of 200-300 characters, the quality of AI-generated proposals improved significantly, and user satisfaction increased along with it.

This is not surprising. The more information users provide, the more accurately the AI can understand them, and the better the results it can return. In that sense, it is not very different from communication between people.

If you are also building AI-related products, I hope these experiences offer some useful inspiration. Remember: users do not necessarily refuse to provide information. More often, the product has not yet asked the right questions in the right way. The core of progressive disclosure is finding the best timing and form for those questions.


If this article helped you, feel free to give us a Star on GitHub and follow the continued development of the HagiCode project. Public beta has already started, and you can experience the full feature set right now by installing it:

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

AI Output Token Optimization: Practicing an Ultra-Minimal Classical Chinese Mode

AI Output Token Optimization: Practicing an Ultra-Minimal Classical Chinese Mode

Section titled “AI Output Token Optimization: Practicing an Ultra-Minimal Classical Chinese Mode”

In AI application development, token consumption directly affects cost. In the HagiCode project, we implemented an “ultra-minimal Classical Chinese output mode” through the SOUL system. Without sacrificing information density, it reduces output tokens by roughly 30-50%. This article shares the implementation details of that approach and the lessons we learned using it.

In AI application development, token consumption is an unavoidable cost issue. This becomes especially painful in scenarios where the AI needs to produce large amounts of content. How do you reduce output tokens without sacrificing information density? The more you think about it, the more frustrating the problem can get.

Traditional optimization ideas mostly focus on the input side: trimming system prompts, compressing context, or using more efficient encoding. But these methods eventually hit a ceiling. Push compression too far, and you start hurting the AI’s comprehension and output quality. That is basically just deleting content, which is not very meaningful.

So what about the output side? Could we get the AI to express the same meaning more concisely?

The question sounds simple, but there is quite a bit hidden beneath it. If you directly ask the AI to “be concise,” it may really give you only a few words. If you add “keep the information complete,” it may drift back to the original verbose style. Constraints that are too strong hurt usability; constraints that are too weak do nothing. Where exactly is the balance point? No one can say for sure.

To solve these pain points, we made a bold decision: start from language style itself and design a configurable, composable constraint system for expression. The impact of that decision may be even larger than you expect. I will get into the details shortly, and the result may surprise you a little.

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

HagiCode is an open-source AI coding assistant that supports multiple AI models and custom configuration. During development, we discovered that AI output token usage was too high, so we designed a solution for it. If you find this approach valuable, that probably says something good about our engineering work. And if that is the case, HagiCode itself may also be worth your attention. Code does not lie.

The full name of the SOUL system is Soul Oriented Universal Language. It is the configuration system used in the HagiCode project to define the language style of an AI Hero. Its core idea is simple: by constraining how the AI expresses itself, it can output content in a more concise linguistic form while preserving informational completeness.

It is a bit like putting a linguistic mask on the AI… though honestly, it is not quite that mystical.

The SOUL system uses a frontend-backend separated architecture:

Frontend (Soul Builder):

  • Built with React + TypeScript + Vite
  • Located in the repos/soul/ directory
  • Provides a visual Soul building interface
  • Supports bilingual use (zh-CN / en-US)

Backend:

  • Built on .NET (C#) + the Orleans distributed runtime
  • The Hero entity includes a Soul field (maximum 8000 characters)
  • Injects Soul into the system prompt through SessionSystemMessageCompiler

Agent Templates generation:

  • Generated from reference materials
  • Output to the /agent-templates/soul/templates/ directory
  • Includes 50 main Catalog groups and 10 orthogonal dimensions

When a Session executes for the first time, the system reads the Hero’s Soul configuration and injects it into the system prompt:

sequenceDiagram
participant UI as User Interface
participant Session as SessionGrain
participant Hero as Hero Repository
participant AI as AI Executor
UI->>Session: Send message (bind Hero)
Session->>Hero: Read Hero.Soul
Session->>Session: Cache Soul snapshot
Session->>AI: Build AIRequest (inject Soul)
AI-->>Session: Execution result
Session-->>UI: Stream response

The injected system prompt format is:

<hero_soul>
[User-defined Soul content]
</hero_soul>

This injection mechanism is implemented in SessionSystemMessageCompiler.cs:

internal static string? BuildSystemMessage(
string? existingSystemMessage,
string? languagePreference,
IReadOnlyList<HeroTraitDto>? traits,
string? soul)
{
var segments = new List<string>();
// ... language preference and Traits handling ...
var normalizedSoul = NormalizeSoul(soul);
if (!string.IsNullOrWhiteSpace(normalizedSoul))
{
segments.Add($"<hero_soul>\n{normalizedSoul}\n</hero_soul>");
}
// ... other system messages ...
return segments.Count == 0 ? null : string.Join("\n\n", segments);
}

Once you have seen the code and understood the principle, that is really all there is to it.

Ultra-minimal Classical Chinese mode is the most representative token-saving strategy in the SOUL system. Its core principle is to use the high semantic density of Classical Chinese to compress output length while preserving complete information.

Classical Chinese has several natural advantages:

  1. Semantic compression: the same meaning can be expressed with fewer characters.
  2. Redundancy removal: Classical Chinese naturally omits many conjunctions and particles common in modern Chinese.
  3. Concise structure: each sentence carries high information density, making it well suited as a vehicle for AI output.

Here is a concrete example:

Modern Chinese output (about 80 characters):

Based on your code analysis, I found several issues. First, on line 23, the variable name is too long and should be shortened. Second, on line 45, you did not handle null values and should add conditional logic. Finally, the overall code structure is acceptable, but it can be further optimized.

Ultra-minimal Classical Chinese output (about 35 characters, saving 56%):

Code reviewed: line 23 variable name verbose, abbreviate; line 45 lacks null handling, add checks. Overall structure acceptable; minor tuning suffices.

The gap is large enough to make you stop and think.

The complete Soul configuration for ultra-minimal Classical Chinese mode is as follows:

{
"id": "soul-orth-11-classical-chinese-ultra-minimal-mode",
"name": "Ultra-Minimal Classical Chinese Output Mode",
"summary": "Use relatively readable Classical Chinese to compress semantic density, convey the meaning with as few words as possible, and retain only conclusions, judgments, and necessary actions, thereby significantly reducing output tokens.",
"soul": "Your persona core comes from the \"Ultra-Minimal Classical Chinese Output Mode\": use relatively readable Classical Chinese to compress semantic density, convey the meaning with as few words as possible, and retain only conclusions, judgments, and necessary actions, thereby significantly reducing output tokens.\nMaintain the following signature language traits: 1. Prefer concise Classical Chinese sentence patterns such as \"can\", \"should\", \"do not\", \"already\", \"however\", and \"therefore\", while avoiding obscure and difficult wording;\n2. Compress each sentence to 4-12 characters whenever possible, removing preamble, pleasantries, repeated explanation, and ineffective modifiers;\n3. Do not expand arguments unless necessary; if the user does not ask a follow-up, provide only conclusions, steps, or judgments;\n4. Do not alter the core persona of the main Catalog; only compress the expression into restrained, classical, ultra-minimal short sentences."
}

There are several key points in this template design:

  1. Clear constraints: 4-12 characters per sentence, remove redundancy, prioritize conclusions.
  2. Avoid obscurity: use concise Classical Chinese sentence patterns and avoid rare, difficult wording.
  3. Preserve persona: only change the mode of expression, not the core persona.

When you keep adjusting configuration, it all comes down to a few parameters in the end.

Besides the Classical Chinese mode, the HagiCode SOUL system also provides several other token-saving modes:

Telegraph-style ultra-minimal output mode (soul-orth-02):

  • Keep every sentence strictly within 10 characters
  • Prohibit decorative adjectives
  • No modal particles, exclamation marks, or reduplication throughout

Short fragmented muttering mode (soul-orth-01):

  • Keep sentences within 1-5 characters
  • Simulate fragmented self-talk
  • Weaken explicit logic and prioritize emotional transmission

Guided Q&A mode (soul-orth-03):

  • Use questions to guide the user’s thinking
  • Reduce direct output content
  • Lower token usage through interaction

Each of these modes emphasizes a different design direction, but the core goal is the same: reduce output tokens while preserving information quality. There are many roads to Rome; some are simply easier to walk than others.

One powerful feature of the SOUL system is support for cross-combining main Catalogs and orthogonal dimensions:

  • 50 main Catalog groups: define the base persona (such as healing style, top-student style, aloof style, and so on)
  • 10 orthogonal dimensions: define the mode of expression (such as Classical Chinese, telegraph-style, Q&A style, and so on)
  • Combination effect: can generate 500+ unique language-style combinations

For example, you can combine “Professional Development Engineer” with “Ultra-Minimal Classical Chinese Output Mode” to create an AI assistant that is both professional and concise. This flexibility allows the SOUL system to adapt to many different scenarios. You can mix and match however you like; there are more combinations than you are likely to exhaust.

Visit soul.hagicode.com and follow these steps:

  1. Select a main Catalog (for example, “Professional Development Engineer”)
  2. Select an orthogonal dimension (for example, “Ultra-Minimal Classical Chinese Output Mode”)
  3. Preview the generated Soul content
  4. Copy the generated Soul configuration

It is mostly just point-and-click, so there is probably not much more to say.

Apply the Soul configuration to a Hero through the web interface or API:

// Hero Soul update example
const heroUpdate = {
soul: "Your persona core comes from the \"Ultra-Minimal Classical Chinese Output Mode\": ...",
soulCatalogId: "soul-orth-11-classical-chinese-ultra-minimal-mode",
soulDisplayName: "Ultra-Minimal Classical Chinese Output Mode",
soulStyleType: "orthogonal-dimension",
soulSummary: "Use relatively readable Classical Chinese to compress semantic density..."
};
await updateHero(heroId, heroUpdate);

Users can fine-tune a preset template or write one from scratch. Here is a custom example for a code review scenario:

You are a code reviewer who pursues extreme concision.
All output must follow these rules:
1. Only point out specific problems and line numbers
2. Each issue must not exceed 15 characters
3. Use concise terms such as "should", "must", and "do not"
4. Do not provide extra explanation
Example output:
- Line 23: variable name too long, should abbreviate
- Line 45: null not handled, must add checks
- Line 67: logic redundant, can simplify

You can revise the template however you like. A template is only a starting point anyway.

Compatibility:

  • Classical Chinese mode works with all 50 main Catalog groups
  • Can be combined with any base persona
  • Does not change the core persona of the main Catalog

Caching mechanism:

  • Soul is cached when the Session executes for the first time
  • The cache is reused within the same SessionId
  • Modifying Hero configuration does not affect Sessions that have already started

Constraints and limits:

  • The maximum length of the Soul field is 8000 characters
  • Heroes without a Soul field in historical data can still be used normally
  • Soul and style equipment slots are independent and do not overwrite each other

According to real test data from the project, the results after enabling ultra-minimal Classical Chinese mode are as follows:

ScenarioOriginal output tokensClassical Chinese modeSavings
Code review85042051%
Technical Q&A62038039%
Solution suggestions110068038%
Average--30-50%

The data comes from actual usage statistics in the HagiCode project, and exact results vary by scenario. Still, the saved tokens add up, and your wallet will appreciate it.

The HagiCode SOUL system offers an innovative way to optimize AI output: reduce token consumption by constraining expression rather than compressing the information itself. As its most representative approach, ultra-minimal Classical Chinese mode has delivered 30-50% token savings in real-world use.

The core value of this approach lies in the following:

  1. Preserve information quality: instead of simply truncating output, it expresses the same content more efficiently.
  2. Flexible and composable: supports 500+ combinations of personas and expression styles.
  3. Easy to use: Soul Builder provides a visual interface, so no coding is required.
  4. Production-grade stability: validated in the project and capable of large-scale use.

If you are also building AI applications, or if you are interested in the HagiCode project, feel free to reach out. The meaning of open source lies in progressing together, and we also look forward to seeing your own innovative uses. The saying may be old, but it remains true: one person may go fast, but a group goes farther.


If this article helped you:

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

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

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

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

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

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

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

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

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

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

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

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

This layered design brings several practical technical advantages:

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

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

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

The benefits of this design are fairly clear:

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

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

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

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

The value of this implementation is significant:

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

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

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

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

This gives us several benefits:

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

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

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

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

The benefits of session pooling include:

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

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

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

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

The advantages of Orleans integration are substantial:

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

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

Here is a complete configuration example:

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

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

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

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

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

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

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

1. Use a tool allowlist

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

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

2. Set reasonable timeouts

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

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

3. Enable session reuse

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

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

4. Handle streaming responses

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

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

5. Error handling and retries

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

Proper error handling and retry logic improve overall system stability.

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

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

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

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


If this article helped you:


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

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

The Hallucination Problem in AI Coding Assistants: How to Achieve Specification-driven Development with OpenSpec

The Hallucination Problem in AI Coding Assistants: How to Achieve Specification-driven Development with OpenSpec

Section titled “The Hallucination Problem in AI Coding Assistants: How to Achieve Specification-driven Development with OpenSpec”

AI coding assistants are powerful, but they often generate code that does not match real requirements or violates project conventions. This article shares how the HagiCode project uses the OpenSpec workflow to implement specification-driven development and significantly reduce the risk of AI hallucinations through a structured proposal mechanism.

Anyone who has used GitHub Copilot or ChatGPT to write code has probably had this experience: the code generated by AI looks polished, but once you actually use it, problems show up everywhere. Maybe it uses the wrong component from the project, maybe it ignores the team’s coding standards, or maybe it writes a large chunk of logic based on assumptions that do not even exist.

This is the so-called “AI hallucination” problem. In programming, it appears as code that seems reasonable on the surface but does not actually fit the real state of the project.

There is also something a bit frustrating about this. As AI coding assistants become more widespread, the problem becomes more serious. After all, AI lacks an understanding of project history, architectural decisions, and coding conventions, and when given too much freedom it can “creatively” generate code that does not match reality. It is a bit like writing an article: without structure, it is easy to wander off into imagination, even though the real situation is far more grounded.

To solve these pain points, we made a bold decision: instead of trying to make AI smarter, we put it inside a “specification” cage. The change this decision brought was probably bigger than you might expect, and I will explain that shortly.

The approach shared in this article comes from our practical experience in the HagiCode project. HagiCode is an open-source AI coding assistant project dedicated to solving real problems in AI programming through structured engineering practices.

Before diving into the solution, let us first look at where the problem actually comes from. After all, if you understand both yourself and your opponent, you can fight a hundred battles without defeat. Applied to AI, that saying is still surprisingly fitting.

AI models are trained on public code repositories, but your project has its own history, conventions, and architectural decisions. AI cannot directly access this kind of “implicit knowledge,” so the code it generates is often disconnected from the actual project.

This is not entirely the AI’s fault. It has never lived inside your project, so how could it know all of your unwritten rules? Like a brand-new intern, not understanding the local customs is normal. The only issue is that the cost can be rather high.

When you ask AI, “Help me implement a user authentication feature,” it may generate code in almost any form. Without clear constraints, AI will implement things in the way it “thinks” is reasonable instead of following your project’s requirements.

That is like asking someone who has never learned your project standards to improvise freely. How could that not cause trouble? It is not even that the AI is being irresponsible; it just has no idea what responsibility means in this context.

After AI generates code, if there is no structured review process, code based on false assumptions can go directly into the repository. By the time the problem is discovered in testing or even in production, the cost is already far too high.

That is like trying to mend the pen after the sheep are already gone. The principle is obvious, but in practice people often still find the extra work bothersome. Before things go wrong, who really wants to spend more time up front?

OpenSpec: The Answer to Specification-driven Development

Section titled “OpenSpec: The Answer to Specification-driven Development”

HagiCode chose OpenSpec as the solution. The core idea is simple: all code changes must go through a structured proposal workflow, turning abstract ideas into executable implementation plans.

That may sound grand, but in plain terms it just means making AI write the requirements document before writing the code. As the old saying goes, preparation leads to success, and lack of preparation leads to failure.

OpenSpec is an npm-based command-line tool (@fission-ai/openspec) that defines a standard proposal file structure and validation mechanism. Put simply, it makes AI “write the requirements document” before it writes code.

A three-step workflow to prevent hallucinations

Section titled “A three-step workflow to prevent hallucinations”

OpenSpec ensures proposal quality through a three-step workflow:

Step 1: Initialize the proposal - Set the session state to Openspecing Step 2: Intermediate processing - Keep the Openspecing state while gradually refining the artifacts Step 3: Complete the proposal - Transition to the Reviewing state

There is a clever detail in this design: the first step uses the ProposalGenerationStart type, and completing it does not trigger a state transition. This ensures that the review stage is not entered too early before the entire multi-step workflow is finished.

This detail is actually quite interesting. It is like cooking: if you lift the lid before the heat is right, nothing will turn out well. Only by moving step by step with a bit of patience can you end up with a good dish.

// Implementation in the HagiCode project
public enum MessageAssociationType
{
ProposalGeneration = 2,
ProposalExecution = 3,
/// <summary>
/// Marks the start of the three-step proposal generation workflow
/// Does not transition to the Reviewing state when completed
/// </summary>
ProposalGenerationStart = 5
}

Every OpenSpec proposal follows the same directory structure:

openspec/
├── changes/ # Active and archived changes
│ ├── {change-name}/
│ │ ├── proposal.md # Proposal description
│ │ ├── design.md # Design document
│ │ ├── specs/ # Technical specifications
│ │ └── tasks.md # Executable task list
│ └── archive/ # Archived changes
└── specs/ # Standalone specification library

According to statistics from the HagiCode project, there are already more than 4,000 archived changes and over 150,000 lines of specification files. This historical accumulation not only gives AI clear guidance to follow, but also provides the team with a valuable knowledge base.

It is a bit like the classics left behind by earlier generations. Read enough of them and patterns begin to emerge. The only difference is that these classics are stored in files instead of written on bamboo slips.

The system implements multiple layers of validation to ensure proposal quality:

// Validate that required files exist
ValidateProposalFiles()
// Validate prerequisites before execution
ValidateExecuteAsync()
// Validate start conditions
ValidateStartAsync()
// Validate archive conditions
ValidateArchiveAsync()
// Validate proposal name format (kebab-case)
ValidateNameFormat()

These validations are like gatekeepers at multiple checkpoints. Only truly qualified proposals can pass through. It may look tedious, but it is still much better than letting poor code enter the repository.

When AI runs inside HagiCode, it uses predefined Handlebars templates. These templates contain explicit step-by-step instructions and protective guardrails. For example:

  • Do not continue before understanding the user’s intent
  • Do not generate unvalidated code
  • Require the user to provide the name again if it is invalid
  • If the change already exists, suggest using the continue command instead of recreating it

This way of “dancing in shackles” actually helps AI focus more on understanding requirements and generating code that follows standards. Constraints are not always a bad thing. Sometimes too much freedom is exactly what creates chaos.

Practice: How to Use OpenSpec in a Project

Section titled “Practice: How to Use OpenSpec in a Project”
Terminal window
npm install -g @fission-ai/openspec@1
openspec --version # Verify the installation

The openspec/ folder structure will be created automatically in the project root.

There is not much mystery in this step. It is just tool installation, which everyone understands. Just remember to use @fission-ai/openspec@1; newer versions may have pitfalls, and stability matters most.

In the HagiCode conversation interface, use the shortcut command:

/opsx:new

Or specify a change name and target repository:

/opsx:new "add-user-auth" --repos "repos/web"

Creating a proposal is like outlining an article before writing it. Once you have an outline, the rest becomes much easier. Many people prefer to jump straight into writing, only to realize halfway through that the idea does not hold together. That is when the real headache begins.

Use /opsx:continue to generate the required artifacts step by step:

proposal.md - Describes the purpose and scope of the change

# Proposal: Add User Authentication
## Why
The current system lacks user authentication and cannot protect sensitive APIs.
## What Changes
- Add JWT authentication middleware
- Implement login/registration APIs
- Update frontend integration

design.md - Detailed technical design

# Design: Add User Authentication
## Context
The system currently uses public APIs, so anyone can access them...
## Decisions
1. Choose JWT instead of Session...
2. Use the HS256 algorithm...
## Risks
- Risk of token leakage...
- Mitigation measures...

specs/ - Technical specifications and test scenarios

# user-auth Specification
## Requirements
### Requirement: JWT Token Generation
The system SHALL use the HS256 algorithm to generate JWT tokens.
#### Scenario: Valid login
- WHEN the user provides valid credentials
- THEN the system SHALL return a valid JWT token

tasks.md - Executable task list

# Tasks: Add User Authentication
## 1. Backend Changes
- [ ] 1.1 Create AuthController
- [ ] 1.2 Implement JWT middleware
- [ ] 1.3 Add unit tests

These artifacts are a lot like drafts for an article. Once the draft is complete, the main text flows naturally. Many people dislike writing drafts because they think it wastes time, but in reality that is often where the clearest thinking happens.

After all artifacts are complete:

/opsx:apply

AI will read all context files and execute tasks step by step according to the checklist in tasks.md. At this point, because the specification is already clear, the quality of the generated code is much higher.

By this stage, half the work is already done. Once there is a clear task list, the rest is simply executing it step by step. The problem is that many people skip the earlier steps and jump straight here, and then quality naturally becomes hard to guarantee.

After the change is completed:

/opsx:archive

Move the completed change into the archive/ directory so it can be reviewed and reused later.

Archiving matters. It is like carefully storing away a finished article. When a similar problem appears in the future, looking back through old records may provide the answer. Many people find it troublesome, but these accumulated materials are often the most valuable assets.

Use kebab-case, start with a letter, and include only lowercase letters, numbers, and hyphens:

  • add-user-auth
  • AddUserAuth
  • add--user-auth

Naming rules may seem minor, but consistency is always worth something. In software, consistency matters even when people do not always pay attention to it.

  1. Using the wrong type in step 1 of the three-step workflow - This causes the state to transition too early
  2. Forgetting to trigger the state transition in the final step - This leaves the workflow stuck in the Openspecing state
  3. Skipping review and executing directly - You should validate that all artifacts are complete first

These mistakes are all common for beginners. Experienced people naturally know how to avoid them. Still, everyone becomes experienced eventually, and taking a few detours is part of the process. The only hope is to avoid taking too many.

OpenSpec supports managing multiple proposals at the same time, which is especially useful for large features:

Terminal window
# View all active changes
openspec list
# Switch to a specific change
openspec apply "add-user-auth"
# View change status
openspec status --change "add-user-auth"

Managing multiple changes is like writing several articles at once. It takes some technique and patience, but once you get used to it, it becomes natural enough.

Understanding state transitions helps with troubleshooting:

Init → Drafting → Openspecing → Reviewing → Executing → ExecutionCompleted → Completed → Archived
  • Openspecing: Generating the plan
  • Reviewing: Under review (artifacts can be revised repeatedly)
  • Executing: In execution (applying tasks.md)

A state machine is, in the end, just a set of rules. Rules can feel annoying at times, but more often they are useful. As the saying goes, without rules, nothing can be accomplished properly.

Through the OpenSpec workflow, the HagiCode project has achieved significant results in addressing the AI hallucination problem:

  1. Fewer hallucinations - AI must follow a structured specification instead of generating code arbitrarily
  2. Higher quality - Multi-layer validation ensures changes comply with project standards
  3. Faster collaboration - Archived changes provide references for future development
  4. Traceability - Every change has a complete record of proposal, design, specification, and tasks

This approach does not make AI smarter. It puts AI inside a “specification” cage. Practice has shown that dancing in shackles can actually lead to a better performance.

The principle is simple. Constraints are not necessarily bad. Like writing, having a format to follow often makes it easier to produce good work. Many people dislike constraints because they think constraints limit creativity, but creativity also needs the right soil to grow.

If you are also using AI coding assistants and have run into similar problems, give OpenSpec a try. Specification-driven development may seem to add extra steps, but that early investment pays back many times over in code quality and maintenance efficiency.

Sometimes slowing down a little is actually the fastest way forward. Many people just do not realize it yet.


If this article helped you, feel free to give us a Star on GitHub. The HagiCode public beta has already started, and you can join the experience by installing it now.


That is about enough for this article. There is nothing especially profound here, just a summary of a few practical lessons. I hope it is useful to everyone. Sharing is a good thing: you learn something yourself, and others learn something too.

Still, an article is only an article. Practice is what really matters. Knowledge from the page always feels shallow until you apply it yourself.

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

Building Elegant New User Onboarding in React Projects: HagiCode's driver.js Practice

Building Elegant New User Onboarding in React Projects: HagiCode’s driver.js Practice

Section titled “Building Elegant New User Onboarding in React Projects: HagiCode’s driver.js Practice”

When users open your product for the first time, do they really know where to start? In this article, I want to talk a bit about how we used driver.js for new user onboarding in the HagiCode project. Consider it a small practical example to get the conversation started.

Have you ever run into this situation? A new user signs up for your product, opens the page, and immediately looks lost. They scan around, unsure what to click or what to do next. As developers, we often assume users will just “explore on their own” because, after all, human curiosity is limitless. But reality is different: most users quietly leave within minutes if they cannot find the right entry point, as if the story begins suddenly and ends just as naturally.

New user onboarding is an important way to solve this problem, but building it well is not that simple. A good onboarding system needs to:

  • Precisely locate page elements and highlight them
  • Support multi-step onboarding flows
  • Remember the user’s choice (complete or skip)
  • Avoid affecting page performance and normal interaction
  • Keep the code structure clear and easy to maintain

While building HagiCode, we ran into the same challenge. HagiCode is an AI coding assistant, and its core workflow is an OpenSpec workflow that looks like this: “the user creates a Proposal -> the AI generates a plan -> the user reviews it -> the AI executes it.” For users encountering this concept for the first time, the workflow is completely new, so they need solid onboarding to get started quickly. New things always take a little time to get used to.

The approach shared in this article comes from our hands-on experience in the HagiCode project. HagiCode is a Claude-based AI coding assistant that helps developers complete coding tasks more efficiently through the OpenSpec workflow. You can view our open-source code on GitHub.

During the technical evaluation phase, we looked at several mainstream onboarding libraries. Each one had its own strengths:

  • Intro.js: Powerful, but relatively large, and style customization is somewhat complex
  • Shepherd.js: Well-designed API, but a bit too “heavy” for our use case
  • driver.js: Lightweight, concise, intuitive API, and works well in the React ecosystem

In the end, we chose driver.js. There was no especially dramatic reason. The choice mainly came down to these considerations:

  1. Lightweight: The core library is small and does not significantly increase bundle size
  2. Simple API: The configuration is clear and intuitive, so it is easy to pick up
  3. Flexible: Supports custom positioning, styling, and interaction behavior
  4. Dynamic import: Can be loaded on demand without affecting first-screen performance

With technology selection, there is rarely a universally best answer. Usually, there is only the option that fits best.

driver.js has a very intuitive configuration model. Here is the core configuration we use in the HagiCode project:

import { driver } from 'driver.js';
import 'driver.js/dist/driver.css';
const newConversationDriver = driver({
allowClose: true, // Allow users to close the guide
animate: true, // Enable animations
overlayClickBehavior: 'close', // Close the guide when the overlay is clicked
disableActiveInteraction: false, // Keep elements interactive
showProgress: false, // Do not show the progress bar (we manage progress ourselves)
steps: guideSteps // Array of guide steps
});

The reasoning behind these settings is:

  • allowClose: true - Respect the user’s choice and do not force them to finish the guide
  • disableActiveInteraction: false - Some steps require real user actions, such as typing input, so interaction cannot be disabled
  • overlayClickBehavior: 'close' - Give users a quick way to exit

Persisting onboarding state is critical. We do not want to restart the guide every time the page refreshes, because that gets annoying fast. HagiCode uses localStorage to manage guide state:

export type GuideState = 'pending' | 'dismissed' | 'completed';
export interface UserGuideState {
session: GuideState;
detailGuides: Record<string, GuideState>;
}
// Read state
export const getUserGuideState = (): UserGuideState => {
const state = localStorage.getItem('userGuideState');
return state ? JSON.parse(state) : { session: 'pending', detailGuides: {} };
};
// Update state
export const setUserGuideState = (state: UserGuideState) => {
localStorage.setItem('userGuideState', JSON.stringify(state));
};

We define three states:

  • pending: The guide is still in progress, and the user has not completed or skipped it
  • dismissed: The user closed the guide proactively
  • completed: The user completed all steps

For Proposal detail page onboarding, we also support more fine-grained state tracking through the detailGuides map, because one Proposal can go through multiple stages - draft, review, and execution complete - and each stage needs different guidance. The state of things is always changing, and onboarding should reflect that.

driver.js uses CSS selectors to locate target elements. HagiCode follows a simple convention: use a custom data-guide attribute to mark onboarding targets:

const steps = [
{
element: '[data-guide="launch"]',
popover: {
title: 'Start a New Conversation',
description: 'Click here to create a new conversation session...'
}
}
];

In components, it looks like this:

<button data-guide="launch" onClick={handleLaunch}>
New Conversation
</button>

The benefits of this approach are:

  • Avoid conflicts with business styling class names
  • Clear semantics, so you can immediately tell the element is related to onboarding
  • Easier to manage and maintain consistently

Because the onboarding feature is only needed in specific scenarios, such as a user’s first visit, we use dynamic imports to optimize initial loading performance:

const initNewUserGuide = async () => {
// Dynamically import driver.js
const { driver } = await import('driver.js');
await import('driver.js/dist/driver.css');
// Initialize the guide
const newConversationDriver = driver({
// ...configuration
});
newConversationDriver.drive();
};

This way, driver.js and its stylesheet are only loaded when needed and do not affect first-screen performance. Not many people enjoy waiting for something they do not even need yet.

HagiCode implements two onboarding paths that cover the user’s core scenarios.

This onboarding path helps users complete the entire flow from creating a conversation to submitting their first complete Proposal:

  1. launch - Start the guide and introduce the “New Conversation” button
  2. compose - Guide the user to type a request in the input box
  3. send - Guide the user to click the send button
  4. proposal-launch-readme - Guide the user to create a README Proposal
  5. proposal-compose-readme - Guide the user to edit the README request content
  6. proposal-submit-readme - Guide the user to submit the README Proposal
  7. proposal-launch-agents - Guide the user to create an AGENTS.md Proposal
  8. proposal-compose-agents - Guide the user to edit the AGENTS.md request
  9. proposal-submit-agents - Guide the user to submit the AGENTS.md Proposal
  10. proposal-wait - Explain that the AI is processing and the user should wait a moment

The idea behind this path is to let users experience HagiCode’s core workflow firsthand through two real Proposal creation tasks, one for README and one for AGENTS.md. There is a big difference between hearing about a workflow and going through it yourself.

The following screenshots correspond to a few key points in the session onboarding flow:

Session onboarding: starting from creating a conversation session

The first step of session onboarding takes the user to the entry point for creating a new Conversation session.

Session onboarding: entering the first request

Next, the guide prompts the user to type their first request into the input box, lowering the barrier to getting started.

Session onboarding: sending the first message

After the input is complete, the guide clearly prompts the user to send the first message so the action flow feels more connected.

Session onboarding: waiting in the session list for continued execution

Once both Proposals have been created, the guide returns to the session list and lets the user know that the next step is simply to wait for the system to continue execution and refresh.

When users enter the Proposal detail page, HagiCode triggers the corresponding guide based on the Proposal’s current state:

  1. drafting (draft stage) - Guide the user to review the AI-generated plan
  2. reviewing (review stage) - Guide the user to execute the plan
  3. executionCompleted (completed stage) - Guide the user to archive the plan

The defining characteristic of this guide is that it is state-driven: it dynamically decides which onboarding step to show based on the Proposal’s actual state. Things change, and onboarding should change with them.

The screenshot below shows the Proposal detail page in its onboarding state during the drafting phase:

Proposal detail onboarding: generate the plan first during drafting

At this stage, the guide focuses the user’s attention on the key action of generating a plan, so they do not wonder what to do first when entering the detail page for the first time.

In React applications, the target onboarding element may not have finished rendering yet, for example because asynchronous data is still loading. To handle this, HagiCode implements a retry mechanism:

const waitForElement = (selector: string, maxRetries = 10, interval = 100) => {
let retries = 0;
return new Promise<HTMLElement>((resolve, reject) => {
const checkElement = () => {
const element = document.querySelector(selector) as HTMLElement;
if (element) {
resolve(element);
} else if (retries < maxRetries) {
retries++;
setTimeout(checkElement, interval);
} else {
reject(new Error(`Element not found: ${selector}`));
}
};
checkElement();
});
};

Call this function before initializing the guide to make sure the target element already exists. Sometimes waiting a little longer is worth it.

Based on HagiCode’s practical experience, here are a few key best practices:

Do not force users to complete onboarding. Some users are explorers by nature and prefer to figure things out on their own. Provide a clear “Skip” button, remember their choice, and do not interrupt them again next time.

2. Keep Onboarding Content Short and Sharp

Section titled “2. Keep Onboarding Content Short and Sharp”

Each onboarding step should focus on a single goal:

  • Title: Short and clear, ideally no more than a few words
  • Description: Get straight to the point and tell the user what this is and why it matters

Avoid long-winded explanations. User attention is limited during onboarding, and the more you say, the less likely they are to read it.

Use stable element markers that do not change often. The custom data-guide attribute is a good choice. Avoid depending on class names or DOM structure, because those are easy to change during refactors. Code changes all the time, but some anchors should stay stable when possible.

HagiCode includes complete test cases for the onboarding feature:

describe('NewUserConversationGuide', () => {
it('should initialize guide state correctly', () => {
const state = getUserGuideState();
expect(state.session).toBe('pending');
});
it('should update guide state correctly', () => {
setUserGuideState({ session: 'completed', detailGuides: {} });
const state = getUserGuideState();
expect(state.session).toBe('completed');
});
});

Tests help ensure that refactoring does not accidentally break onboarding behavior. Nobody wants a small code change to quietly damage previously working functionality.

  • Use dynamic imports to lazy-load the onboarding library
  • Avoid initializing onboarding logic after the user has already completed the guide
  • Consider the performance impact of animations, and disable them on lower-end devices if needed

Performance, like many things in life, deserves a bit of careful budgeting.

New user onboarding is an important part of improving product user experience. In the HagiCode project, we used driver.js to build a complete onboarding system that covers the full workflow from session creation to Proposal execution.

The core points we hope to share through this article are:

  1. Technical choices should match actual needs: driver.js is not the most powerful option, but it is the best fit for us
  2. State management is critical: Use localStorage to persist onboarding state and avoid repeatedly interrupting users
  3. Onboarding design should stay focused: Each step should solve one problem, and no more
  4. Code structure should stay clear: Separate onboarding configuration, state management, and UI logic to make maintenance easier

If you are adding new user onboarding to your own project, I hope the practical experience in this article helps. There is nothing especially mystical about this kind of technology. Keep trying, keep summarizing what you learn, and things gradually get easier.

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

Typing Is Slower Than Talking, and Talking Is Slower Than a Screenshot - Multimodal Input Practices for AI Coding Assistants

Typing Is Slower Than Talking, and Talking Is Slower Than a Screenshot - Multimodal Input Practices for AI Coding Assistants

Section titled “Typing Is Slower Than Talking, and Talking Is Slower Than a Screenshot - Multimodal Input Practices for AI Coding Assistants”

Writing code has a speed limit no matter how fast you type. Sometimes something you could say in one sentence takes forever to type out; sometimes one screenshot explains everything, yet you still have to describe it with a pile of text. This article talks about what we ran into while building HagiCode, from speech recognition to image uploads. In the end, we just wanted to make an AI coding assistant a little easier to use.

While building HagiCode, we noticed a problem - or rather, a problem that naturally surfaced once people started using it heavily: relying on typing alone can be tiring.

Think about it: interaction between users and the Agent is a core scenario. But if every exchange requires nonstop typing at the keyboard, the efficiency is not great:

  1. Typing is too slow: For complicated issues, like error messages or UI problems, typing everything out can take half a minute, while saying it aloud might take ten seconds. That gap is real.

  2. Images are more direct: Sometimes the UI throws an error, sometimes you want to compare a design draft, and sometimes you need to show a code structure. “A picture is worth a thousand words” may be an old saying, but it still holds up. Letting AI directly “see” the problem is much clearer than describing it for ages.

  3. Interaction should feel natural: Modern AI assistants should support text, voice, and images. Users should be able to choose whichever input method feels most natural.

So we decided to add speech recognition and image upload support to HagiCode to make Agent interactions more convenient. If users can type a little less, that is already a win.

The solutions shared in this article come from our hands-on work in the HagiCode project - or, more accurately, from lessons learned while stumbling through quite a few pitfalls.

HagiCode is an open-source AI coding assistant project with a simple goal: use AI to improve development efficiency. As we kept building, it became clear that users strongly wanted multimodal input. Sometimes speaking one sentence is faster than typing a long paragraph, and sometimes a screenshot is far clearer than a long explanation.

Those needs pushed us forward, and that is how features like speech recognition and image uploads eventually took shape. Users can now interact with AI in the most natural way available to them, and that feels good.

Technical Challenges in Speech Recognition

Section titled “Technical Challenges in Speech Recognition”

When building speech recognition, we ran into a tricky issue: the browser WebSocket API does not support custom HTTP headers.

The speech recognition service we chose was ByteDance’s Doubao Speech Recognition API. Unfortunately, this API requires authentication information such as accessToken and secretKey to be passed through HTTP headers. That created an immediate technical conflict:

// The browser WebSocket API does not support this approach
const ws = new WebSocket('wss://api.com/ws', {
headers: {
'Authorization': 'Bearer token' // Not supported
}
});

We basically had two options:

  1. URL query parameter approach: put the authentication info in the URL

    • The advantage is that it is simple to implement
    • The downside is that credentials are exposed to the frontend, which is insecure; some APIs also require header-based authentication
  2. Backend proxy approach: implement a WebSocket proxy on the backend

    • The advantage is that credentials remain securely stored on the backend and the solution is fully compatible with API requirements
    • The downside is that implementation is a bit more complex

In the end, we chose the backend proxy approach. Security is not something you compromise on.

Our requirements for image uploads were actually pretty straightforward:

  1. Multiple upload methods: click to select a file, drag and drop, and paste from the clipboard
  2. File validation: type restrictions (PNG, JPG, WebP, GIF) and size limits (5-10 MB) are basic requirements
  3. User experience: upload progress, previews, and error messages so users always know what is happening
  4. Security: server-side validation and protection against malicious file uploads are essential

Speech Recognition: WebSocket Proxy Architecture

Section titled “Speech Recognition: WebSocket Proxy Architecture”

We designed a three-layer architecture for speech recognition and found a path that worked:

Browser WebSocket
|
| ws://backend/api/voice/ws
| (binary audio)
v
Backend Proxy
|
| wss://openspeech.bytedance.com/ (with auth header)
v
Doubao API

Core component implementations:

  1. Frontend AudioWorklet processor:
class AudioProcessorWorklet extends AudioWorkletProcessor {
process(inputs, outputs, parameters) {
const input = inputs[0]?.[0];
if (!input) return true;
// Resample to 16 kHz (required by the Doubao API)
const samples = this.resampleAudio(input, 48000, 16000);
// Accumulate samples into 500 ms chunks
this.accumulatedSamples.push(...samples);
if (this.accumulatedSamples.length >= 8000) {
// Convert to 16-bit PCM and send
const pcm = this.floatToPcm16(this.accumulatedSamples);
this.port.postMessage({ type: 'audioData', data: pcm.buffer }, [pcm.buffer]);
this.accumulatedSamples = [];
}
return true;
}
}
  1. Backend WebSocket handler (C#):
[HttpGet("ws")]
public async Task GetWebSocket()
{
if (HttpContext.WebSockets.IsWebSocketRequest)
{
await _webSocketHandler.HandleAsync(HttpContext);
}
}
  1. Frontend VoiceTextArea component:
export const VoiceTextArea = forwardRef<HTMLTextAreaElement, VoiceTextAreaProps>(
({ value, onChange, onTextRecognized, maxDuration }, ref) => {
const { isRecording, interimText, volume, duration, startRecording, stopRecording } =
useVoiceRecording({ onTextRecognized, maxDuration });
return (
<div className="flex gap-2">
{/* Voice button */}
<button onClick={handleButtonClick}>
{isRecording ? <VolumeWaveform volume={volume} /> : <Mic />}
</button>
{/* Text input area */}
<textarea value={displayValue} onChange={handleChange} />
</div>
);
}
);

Image Uploads: Multi-Method Upload Component

Section titled “Image Uploads: Multi-Method Upload Component”

We built a full-featured image upload component with support for all three upload methods, covering the most common scenarios users run into.

Core features:

  1. Three upload methods:
// Click to upload
const handleClick = () => fileInputRef.current?.click();
// Drag-and-drop upload
const handleDrop = (e: React.DragEvent) => {
const file = e.dataTransfer.files?.[0];
if (file) uploadFile(file);
};
// Clipboard paste
const handlePaste = (e: ClipboardEvent) => {
for (const item of Array.from(e.clipboardData?.items || [])) {
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) uploadFile(file);
}
}
};
  1. Frontend validation:
const validateFile = (file: File): { valid: boolean; error?: string } => {
if (!acceptedTypes.includes(file.type)) {
return { valid: false, error: 'Only PNG, JPG, JPEG, WebP, and GIF images are allowed' };
}
if (file.size > maxSize) {
return { valid: false, error: `Maximum file size is ${(maxSize / 1024 / 1024).toFixed(1)}MB` };
}
return { valid: true };
};
  1. Backend upload handler (TypeScript):
export const Route = createFileRoute('/api/upload')({
server: {
handlers: {
POST: async ({ request }) => {
const formData = await request.formData();
const file = formData.get('file') as File;
// Validation
const validation = validateFile(file);
if (!validation.isValid) {
return Response.json({ error: validation.error }, { status: 400 });
}
// Save file
const uuid = uuidv4();
const filePath = join(uploadDir, `${uuid}${extension}`);
await writeFile(filePath, buffer);
return Response.json({ url: `/uploaded/${today}/${uuid}${extension}` });
}
}
}
});
  1. Configure the speech recognition service:

    • Open the speech recognition settings page
    • Configure the Doubao Speech AppId and AccessToken
    • Optionally configure hotwords to improve recognition accuracy for domain-specific terms
  2. Use it in the input box:

    • Click the microphone icon on the left side of the input box
    • Start speaking after the waveform animation appears
    • Click the icon again to stop recording
    • The recognized text is automatically inserted at the cursor position
  3. Hotword configuration example:

TypeScript
React
useState
useEffect
  1. Upload methods:

    • Click the upload button to choose a file
    • Drag an image directly into the upload area
    • Use Ctrl+V to paste a screenshot from the clipboard
  2. Supported formats: PNG, JPG, JPEG, WebP, GIF

  3. Size limit: 5 MB by default (configurable)

  1. Speech recognition:

    • Microphone permission is required
    • Use in a quiet environment when possible
    • The maximum supported recording duration is 300 seconds by default (configurable)
  2. Image uploads:

    • Only common image formats are supported
    • Pay attention to file size limits
    • Uploaded images automatically receive a preview URL
  3. Security considerations:

    • Speech recognition credentials are stored on the backend
    • Image uploads go through strict server-side validation
    • HTTPS/WSS is recommended in production environments

After adding speech recognition and image uploads, the HagiCode user experience improved noticeably. Users can now interact with AI in a more natural way - speaking instead of typing, and sharing screenshots instead of describing everything manually. It feels like finally finding a more comfortable way to communicate.

While building this feature, we ran into the problem that browser WebSocket APIs do not support custom headers. In the end, we solved it with a backend proxy approach. That solution not only preserved security, but also laid the groundwork for integrating other authenticated WebSocket services later on.

The image upload component also benefits from supporting multiple upload methods, letting users choose whatever is most convenient in the moment. Clicking, dragging, or pasting all work, and each path gets the job done quickly.

“Typing is slower than talking, and talking is slower than a screenshot” fits the theme here quite well. If you are building a similar AI assistant product, I hope these experiences help, even if only a little.


If this article helped you:

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

Full GLM-5.1 Support and Gemini CLI Integration: HagiCode's Path of Multi-Model Evolution

Full GLM-5.1 Support and Gemini CLI Integration: HagiCode’s Path of Multi-Model Evolution

Section titled “Full GLM-5.1 Support and Gemini CLI Integration: HagiCode’s Path of Multi-Model Evolution”

This article introduces two major recent updates to the HagiCode platform: full support for the Zhipu AI GLM-5.1 model and the successful integration of Gemini CLI as the tenth Agent CLI. Together, these updates further strengthen the platform’s multi-model capabilities and multi-CLI ecosystem.

Time really does move fast. The development of large language models has been rising like bamboo in spring. Not long ago, we were still cheering for “an AI that can write code.” Now we are already in an era of multi-model collaboration and multi-tool integration. Is that exciting? Perhaps. After all, what developers need has never been just the tool itself, but the ease of adapting to different scenarios and switching flexibly when needed.

As an AI-assisted coding platform, HagiCode has recently welcomed two important developments: first, the full integration of Zhipu AI’s GLM-5.1 model; second, the official addition of Gemini CLI as the tenth supported Agent CLI. These two updates may not sound earth-shaking, but they are unquestionably good news for the platform’s continued maturation.

GLM-5.1 is Zhipu AI’s latest flagship model. Compared with GLM-5.0, it offers stronger reasoning, deeper code understanding, and smoother tool calling. More importantly, it is the first GLM model to support image input. What does that mean? It means users can let the AI look directly at a screenshot instead of struggling to describe the problem in words. Once you’ve used that convenience, you immediately understand its value.

At the same time, through the HagiCode.Libs.Providers architecture, HagiCode successfully integrated Gemini CLI into the platform. This is now the tenth Agent CLI. To be honest, getting to this point does bring a modest sense of accomplishment.

It is also worth mentioning that HagiCode’s image upload feature lets users communicate with AI directly through screenshots. Even when running GLM 4.7, the platform still works well and has already helped complete many important build tasks. As for GLM-5.1, naturally, it goes one step further.

The approach shared in this article comes from our practical experience in the HagiCode project. HagiCode is an open-source AI-assisted coding platform designed to provide developers with a flexible and powerful AI programming assistant through a multi-model, multi-CLI architecture. Project repository: github.com/HagiCode-org/site

One of HagiCode’s core strengths is its support for multiple AI programming CLI tools through a unified abstraction layer. The advantage of this design is actually quite simple: new tools can come in, old tools can stay, and the codebase does not turn into chaos. To be fair, that is how everyone would like life to work.

The platform defines supported CLI provider types through the AIProviderType enum:

public enum AIProviderType
{
ClaudeCodeCli = 0, // Claude Code CLI
CodexCli = 1, // GitHub Copilot Codex
GitHubCopilot = 2, // GitHub Copilot
CodebuddyCli = 3, // Codebuddy CLI
OpenCodeCli = 4, // OpenCode CLI
IFlowCli = 5, // IFlow CLI
HermesCli = 6, // Hermes CLI
QoderCli = 7, // Qoder CLI
KiroCli = 8, // Kiro CLI
KimiCli = 9, // Kimi CLI
GeminiCli = 10, // Gemini CLI (new)
}

As you can see, Gemini CLI joins this family as the tenth member. Each CLI has its own distinct characteristics and usage scenarios, so users can choose flexibly based on their needs. After all, many roads lead to Rome; some are simply easier than others.

HagiCode.Libs.Providers provides a unified Provider interface that makes each CLI integration standardized and concise. Taking Gemini CLI as an example:

public class GeminiProvider : ICliProvider<GeminiOptions>
{
private static readonly string[] DefaultExecutableCandidates = ["gemini", "gemini-cli"];
private const string ManagedBootstrapArgument = "--acp";
public string Name => "gemini";
public bool IsAvailable => _executableResolver.ResolveFirstAvailablePath(DefaultExecutableCandidates) is not null;
}

The benefits of this design are:

  • Integrating a new CLI only requires implementing one Provider class
  • Unified lifecycle management and session pooling
  • Automated alias resolution and executable discovery

Put plainly, this design turns complicated things into simpler ones and makes life a bit easier.

The Provider Registry automatically handles alias mapping and registration:

if (provider is GeminiProvider)
{
registry.Register(provider.Name, provider, ["gemini-cli"]);
continue;
}

This means users can invoke Gemini CLI with either gemini or gemini-cli, and the system will recognize it automatically. It is like a friend with both a formal name and a nickname - either way, people know who you mean.

GLM-5.1 is Zhipu AI’s latest flagship model, and HagiCode has completed full support for it.

HagiCode manages all supported models through the Secondary Professions Catalog. Here is the configuration for the GLM series:

Model IDNameSupportsImageCompatible CLI Families
glm-4.7GLM 4.7-claude, codebuddy, hermes, qoder, kiro
glm-5GLM 5-claude, codebuddy, hermes, qoder, kiro
glm-5-turboGLM 5 Turbo-claude, codebuddy, hermes, qoder, kiro
glm-5.0GLM 5.0 (Legacy)-claude, codebuddy, hermes, qoder, kiro
glm-5.1GLM 5.1trueclaude, codebuddy, hermes, qoder, kiro

The key characteristics of GLM-5.1 can be summarized as follows:

  • A standalone version identifier with no legacy baggage
  • The first GLM model to support image input
  • Stronger reasoning and code understanding
  • Broad multi-CLI compatibility

At the code level, the key difference between GLM-5.1 and GLM-5.0 is shown here:

// GLM-5.0 (Legacy) - contains special retention logic
private const string Glm50CodebuddySecondaryProfessionId = "secondary-glm-5-codebuddy";
private const string Glm50CodebuddyModelValue = "glm-5.0";
// GLM-5.1 - standalone new model identifier
private const string Glm51SecondaryProfessionId = "secondary-glm-5-1";
private const string Glm51ModelValue = "glm-5.1";

GLM-5.0 carries the “Legacy” label because it is an old version identifier retained for backward compatibility. GLM-5.1, by contrast, is a brand-new standalone version with no historical burden. Some things stay in the past; others travel lighter and move faster.

Here is a configuration example for using GLM-5.1 in HagiCode:

{
"primaryProfessionId": "profession-claude-code",
"secondaryProfessionId": "secondary-glm-5-1",
"model": "glm-5.1",
"reasoning": "high"
}

HagiCode’s image support is implemented through the SupportsImage property on SecondaryProfession:

public class HeroSecondaryProfessionSettingDto
{
public bool SupportsImage { get; set; }
}

In the Secondary Professions Catalog, the GLM-5.1 configuration looks like this:

{
"id": "secondary-glm-5-1",
"supportsImage": true
}

This means users can upload screenshots directly for AI analysis, such as:

  • Screenshots of error messages
  • Problems in a UI screen
  • Data visualization charts
  • Code execution results

There is no longer any need to describe everything manually - just upload the screenshot. The convenience of this feature is obvious once you have used it. Sometimes one look says more than a long explanation.

As the tenth Agent CLI, Gemini CLI is integrated into HagiCode through the standard Provider architecture.

Gemini CLI supports a rich set of configuration options:

public class GeminiOptions
{
public string? ExecutablePath { get; set; }
public string? WorkingDirectory { get; set; }
public string? SessionId { get; set; }
public string? Model { get; set; }
public string? AuthenticationMethod { get; set; }
public string? AuthenticationToken { get; set; }
public Dictionary<string, string?> AuthenticationInfo { get; set; }
public Dictionary<string, string?> EnvironmentVariables { get; set; }
public string[] ExtraArguments { get; set; }
public TimeSpan? StartupTimeout { get; set; }
public CliPoolSettings? PoolSettings { get; set; }
}

These options cover everything from basic setup to advanced features, giving users the flexibility to configure the CLI around their own needs. Everyone’s workflow is different, so a little flexibility is always welcome.

Gemini CLI supports the ACP (Agent Communication Protocol), which is HagiCode’s unified CLI communication standard. Through ACP, different CLIs can interact with the platform in a consistent way, greatly simplifying integration work. In short, it standardizes the complicated parts so everyone can work more easily.

To use Zhipu AI models, you need to configure the corresponding environment variables.

Terminal window
export ANTHROPIC_AUTH_TOKEN="***"
export ANTHROPIC_BASE_URL="https://open.bigmodel.cn/api/anthropic"
Terminal window
export ANTHROPIC_AUTH_TOKEN="your-a...-key"
export ANTHROPIC_BASE_URL="https://coding.dashscope.aliyuncs.com/apps/anthropic"

Once configured, HagiCode can call the GLM-5.1 model normally. It is neither especially hard nor especially easy - you just need to follow the setup as intended.

Speaking of real-world practice, the best example is the HagiCode platform’s own build workflow. HagiCode’s development process has already made full use of AI capabilities.

HagiCode’s platform design is well optimized, so it can still provide a good development experience even with GLM 4.7. The platform has already helped complete multiple important build projects, including:

  • Integration of multiple CLI Providers
  • Implementation of the image upload feature
  • Documentation generation and content publishing

That is actually a good thing. Not everyone needs the newest thing all the time. What suits you best is often what matters most.

After upgrading to GLM-5.1, these capabilities become even stronger:

  • Stronger code understanding, reducing back-and-forth communication
  • More accurate dependency analysis, pointing in the right direction immediately
  • More efficient error diagnosis, locating issues faster
  • Support for image input, accelerating problem descriptions

It is like switching from a bicycle to a car. You can still reach the same destination, but the speed and comfort are not the same.

HagiCode.Libs.Providers provides a unified mechanism for registration and usage:

services.AddHagiCodeLibs();
var gemini = serviceProvider.GetRequiredService<ICliProvider<GeminiOptions>>();
var codebuddy = serviceProvider.GetRequiredService<ICliProvider<CodebuddyOptions>>();
var hermes = serviceProvider.GetRequiredService<ICliProvider<HermesOptions>>();

This dependency injection design keeps usage across different CLIs very concise and also makes unit testing and mocking more convenient. Clean code is a way of being responsible to yourself.

There are a few things to keep in mind in actual use:

  1. API key configuration: Make sure ANTHROPIC_AUTH_TOKEN is set correctly, or the model cannot be called
  2. Model availability: GLM-5.1 needs to be enabled by the corresponding model provider
  3. Image feature: Only models with supportsImage: true can use image upload
  4. CLI installation: Before using Gemini CLI, make sure gemini or gemini-cli is in the system PATH

These may be small details, but small details handled poorly can turn into big problems, so they are worth paying attention to.

With full support for GLM-5.1 and the successful integration of Gemini CLI, HagiCode further strengthens its capabilities as a multi-model, multi-CLI AI programming platform. These updates not only give users more choices, but also demonstrate HagiCode’s forward-looking architecture and scalability.

GLM-5.1’s image support, combined with HagiCode’s screenshot upload feature, makes it possible to let the AI “understand from the image” and greatly reduces the cost of describing problems. And with support for ten CLIs, users can flexibly choose the AI programming assistant that best fits their preferences and scenarios. More choice is almost always a good thing.

Most importantly, HagiCode’s own build practice proves that the platform can already run well and complete complex tasks even with GLM 4.7, while upgrading to GLM-5.1 can further improve development efficiency. Life is often like that too: you do not always need the absolute best, only what suits you. Of course, if what suits you can become even better, then so much the better.

If you are interested in a multi-model, multi-CLI AI programming platform, give HagiCode a try - open source, free, and still evolving. Trying it costs nothing, and it may turn out to be exactly what you need.


If this article helped you:

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

Hagicode and GLM-5.1 Multi-CLI Integration Guide

Hagicode and GLM-5.1 Multi-CLI Integration Guide

Section titled “Hagicode and GLM-5.1 Multi-CLI Integration Guide”

In the Hagicode project, users can choose from multiple CLI tools to drive AI programming assistants, including Claude Code CLI, GitHub Copilot, OpenCode CLI, Codebuddy CLI, Hermes CLI, and more. These CLI tools are general-purpose AI programming tools on their own, but through Hagicode’s abstraction layer, they can flexibly connect to different AI model providers.

Zhipu AI (ZAI) provides an interface compatible with the Anthropic Claude API, allowing these CLI tools to directly use domestic GLM series models. Among them, GLM-5.1 is Zhipu’s latest large language model release, with significant improvements over GLM-5.0.

Hagicode defines 11 CLI provider types through the AIProviderType enum, covering mainstream AI programming CLI tools:

public enum AIProviderType
{
ClaudeCodeCli = 0, // Claude Code CLI
CodexCli = 1, // GitHub Copilot Codex
GitHubCopilot = 2, // GitHub Copilot
CodebuddyCli = 3, // Codebuddy CLI
OpenCodeCli = 4, // OpenCode CLI
IFlowCli = 5, // IFlow CLI
HermesCli = 6, // Hermes CLI
QoderCli = 7, // Qoder CLI
KiroCli = 8, // Kiro CLI
KimiCli = 9, // Kimi CLI
GeminiCli = 10, // Gemini CLI
}

Each CLI has corresponding model parameter configuration and supports the model and reasoning parameters:

private static readonly IReadOnlyDictionary<AIProviderType, IReadOnlyList<string>> ManagedModelParameterKeysByProvider =
new Dictionary<AIProviderType, IReadOnlyList<string>>
{
[AIProviderType.ClaudeCodeCli] = ["model", "reasoning"],
[AIProviderType.CodexCli] = ["model", "reasoning"],
[AIProviderType.OpenCodeCli] = ["model", "reasoning"],
[AIProviderType.HermesCli] = ["model", "reasoning"],
[AIProviderType.CodebuddyCli] = ["model", "reasoning"],
[AIProviderType.QoderCli] = ["model", "reasoning"],
[AIProviderType.KiroCli] = ["model", "reasoning"],
[AIProviderType.GeminiCli] = ["model"], // Gemini does not support the reasoning parameter
// ...
};

Hagicode’s Secondary Professions Catalog defines complete support for the GLM model series:

Model IDNameDefault ReasoningCompatible CLI Families
glm-4.7GLM 4.7highclaude, codebuddy, hermes, qoder, kiro
glm-5GLM 5highclaude, codebuddy, hermes, qoder, kiro
glm-5-turboGLM 5 Turbohighclaude, codebuddy, hermes, qoder, kiro
glm-5.0GLM 5.0 (Legacy)highclaude, codebuddy, hermes, qoder, kiro
glm-5.1GLM 5.1highclaude, codebuddy, hermes, qoder, kiro

Key differences between GLM-5.1 and GLM-5.0

Section titled “Key differences between GLM-5.1 and GLM-5.0”

From the implementation in AcpSessionModelBootstrapper.cs, we can clearly see the differences between GLM-5.1 and GLM-5.0:

GLM-5.1 is a standalone new model identifier with no legacy handling logic:

private const string Glm51ModelValue = "glm-5.1";

Definition in the Secondary Professions Catalog:

{
"id": "secondary-glm-5-1",
"name": "GLM 5.1",
"family": "anthropic",
"summary": "hero.professionCopy.secondary.glm51.summary",
"sourceLabel": "hero.professionCopy.sources.aiSharedAnthropicModel",
"sortOrder": 64,
"supportsImage": true,
"compatiblePrimaryFamilies": [
"claude",
"codebuddy",
"hermes",
"qoder",
"kiro"
],
"defaultParameters": {
"model": "glm-5.1",
"reasoning": "high"
}
}

Zhipu AI provides the most complete GLM model support:

{
"providerId": "zai",
"name": "智谱 AI",
"description": "智谱 AI 提供的 Claude API 兼容服务",
"category": "china-providers",
"apiUrl": {
"codingPlanForAnthropic": "https://open.bigmodel.cn/api/anthropic"
},
"recommended": true,
"region": "cn",
"defaultModels": {
"sonnet": "glm-4.7",
"opus": "glm-5",
"haiku": "glm-4.5-air"
},
"supportedModels": [
"glm-4.7",
"glm-5",
"glm-4.5-air",
"qwen3-coder-next",
"qwen3-coder-plus"
],
"features": ["experimental-agent-teams"],
"authTokenEnv": "ANTHROPIC_AUTH_TOKEN",
"referralUrl": "https://www.bigmodel.cn/claude-code?ic=14BY54APZA",
"documentationUrl": "https://open.bigmodel.cn/dev/api"
}

Features:

  • Supports the widest variety of GLM model variants
  • Provides default mapping across the Sonnet/Opus/Haiku tiers
  • Supports the experimental-agent-teams feature

Claude Code CLI is one of Hagicode’s core CLIs and is configured through the Hero configuration system:

{
"primaryProfessionId": "profession-claude-code",
"secondaryProfessionId": "secondary-glm-5-1",
"model": "glm-5.1",
"reasoning": "high"
}

Corresponding HeroEquipmentCatalogItem configuration:

{
id: 'secondary-glm-5-1',
name: 'GLM 5.1',
family: 'anthropic',
kind: 'model',
primaryFamily: 'claude',
compatiblePrimaryFamilies: ['claude', 'codebuddy', 'hermes', 'qoder', 'kiro'],
defaultParameters: {
model: 'glm-5.1',
reasoning: 'high'
}
}

OpenCode CLI is the most flexible CLI and supports specifying any model in the provider/model format:

Method 1: Use the ZAI provider prefix

{
"primaryProfessionId": "profession-opencode",
"model": "zai/glm-5.1",
"reasoning": "high"
}

Method 2: Use the model ID directly

{
"model": "glm-5.1"
}

Method 3: Frontend configuration UI

In HeroModelEquipmentForm.tsx, OpenCode CLI has a dedicated placeholder hint:

const OPEN_CODE_MODEL_PLACEHOLDER = 'myprovider/glm-4.7';
const modelPlaceholder = primaryProviderType === PCode_Models_AIProviderType.OPEN_CODE_CLI
? OPEN_CODE_MODEL_PLACEHOLDER
: 'gpt-5.4';

Users can enter:

zai/glm-5.1
glm-5.1

OpenCode CLI model parsing logic:

internal OpenCodeModelSelection? ResolveModelSelection(string? rawModel)
{
var normalized = NormalizeOptionalValue(rawModel);
if (normalized == null) return null;
var slashIndex = normalized.IndexOf('/');
if (slashIndex < 0)
{
// No slash: use the model ID directly
return new OpenCodeModelSelection {
ProviderId = string.Empty,
ModelId = normalized,
};
}
// Slash exists: parse the provider/model format
var providerId = normalized[..slashIndex].Trim();
var modelId = normalized[(slashIndex + 1)..].Trim();
return new OpenCodeModelSelection {
ProviderId = providerId,
ModelId = modelId,
};
}

Codebuddy CLI has dedicated legacy handling logic:

{
"primaryProfessionId": "profession-codebuddy",
"model": "glm-5.1",
"reasoning": "high"
}

Note: Codebuddy retains special handling for GLM-5.0 and does not use legacy normalization:

return !string.Equals(providerName, "CodebuddyCli", StringComparison.OrdinalIgnoreCase)
&& string.Equals(normalizedModel, LegacyGlm5TurboModelValue, StringComparison.OrdinalIgnoreCase)
? Glm5TurboModelValue
: normalizedModel;
// For CodebuddyCli, glm-5.0 is not normalized to glm-5-turbo
Terminal window
# Set the API key
export ANTHROPIC_AUTH_TOKEN="***"
# Optional: specify the API endpoint (ZAI uses this endpoint by default)
export ANTHROPIC_BASE_URL="https://open.bigmodel.cn/api/anthropic"
Terminal window
# Set the API key
export ANTHROPIC_AUTH_TOKEN="your-a...-key"
# Specify the Alibaba Cloud endpoint
export ANTHROPIC_BASE_URL="https://coding.dashscope.aliyuncs.com/apps/anthropic"

Compared with GLM-5.0, GLM-5.1 brings the following significant improvements:

According to Zhipu’s official release information, improvements in GLM-5.1 include:

  • Stronger code understanding: More accurate analysis of complex code structures
  • Longer context comprehension: Supports longer conversational context
  • Enhanced tool calling: Higher success rate for MCP tool calls
  • Output stability: Reduces randomness and hallucinations

GLM-5.1 covers all mainstream CLIs supported by Hagicode:

compatiblePrimaryFamilies: [
"claude", // Claude Code CLI
"codebuddy", // Codebuddy CLI
"hermes", // Hermes CLI
"qoder", // Qoder CLI
"kiro" // Kiro CLI
]

Make sure the ANTHROPIC_AUTH_TOKEN environment variable is set correctly. It is the required credential for every CLI to connect to the model.

GLM-5.1 needs to be enabled by the corresponding model provider:

  • The Zhipu AI ZAI platform supports it by default
  • Alibaba Cloud DashScope may require a separate application

When using the provider/model format, make sure the provider ID is correct:

  • Zhipu AI: zai or zhipuai
  • Alibaba Cloud: aliyun or dashscope
  • high is recommended for the best code generation results
  • Gemini CLI does not support the reasoning parameter and will ignore this configuration automatically

Through a unified abstraction layer, Hagicode enables flexible integration between GLM-5.1 and multiple CLIs. Developers can choose the CLI tool that best fits their preferences and usage scenarios, then use the latest GLM-5.1 model through simple configuration.

As Zhipu’s latest model version, GLM-5.1 offers clear improvements over GLM-5.0:

  • An independent version identifier with no legacy burden
  • Stronger reasoning and code understanding
  • Broad multi-CLI compatibility
  • Flexible reasoning level configuration

With the correct environment variables and Hero equipment configured, users can fully unlock the power of GLM-5.1 across different CLI environments.

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

HagiCode Desktop Hybrid Distribution Architecture Explained: How P2P Accelerates Large File Downloads

HagiCode Desktop Hybrid Distribution Architecture Explained: How P2P Accelerates Large File Downloads

Section titled “HagiCode Desktop Hybrid Distribution Architecture Explained: How P2P Accelerates Large File Downloads”

I held this article back for a long time before finally writing it, and I am still not sure whether it reads well. Technical writing is easy enough to produce, but hard to make truly engaging. Then again, I am no great literary master, so I might as well just set down this plain explanation.

Teams building desktop applications will all run into the same headache sooner or later: how do you distribute large files?

It is an awkward problem. Traditional HTTP/HTTPS direct downloads can still hold up when files are small and the number of users is limited. But time is rarely kind. As a project keeps growing, the installation packages grow with it: Desktop ZIP packages, portable packages, web deployment archives, and more. Then the issues start to surface:

  • Download speed is limited by origin bandwidth: no matter how much bandwidth a single server has, it still struggles when everyone downloads at once.
  • Resume support is nearly nonexistent: if an HTTP download is interrupted, you often have to start over from the beginning. That wastes both time and bandwidth.
  • The origin server takes all the pressure: all traffic flows back to a central server, bandwidth costs keep rising, and scalability becomes a real problem.

The HagiCode Desktop project was no exception. When we designed the distribution system, we kept asking ourselves: can we introduce a hybrid distribution approach without changing the existing index.json control plane? In other words, can we use the distributed nature of P2P networks to accelerate downloads while still keeping HTTP origin fallback so the system remains usable in constrained environments such as enterprise networks?

The impact of that decision turned out to be larger than you might expect. Let us walk through it step by step.

The approach shared in this article comes from our real-world experience in the HagiCode project. HagiCode is an open-source AI coding assistant project focused on helping development teams improve engineering efficiency. The project spans multiple subsystems, including the frontend, backend, desktop launcher, documentation, build pipeline, and server deployment.

The Desktop hybrid distribution architecture is exactly the kind of solution HagiCode refined through real operational experience and repeated optimization. If this design proves useful, then perhaps it also shows that HagiCode itself is worth paying attention to.

The project’s GitHub repository is HagiCode-org/site. If it interests you, feel free to give it a Star and save it for later.

Core Design Philosophy: P2P First, HTTP Fallback

Section titled “Core Design Philosophy: P2P First, HTTP Fallback”

At its heart, the hybrid distribution model can be summarized in a single sentence: P2P first, HTTP fallback.

The key lies in the word “hybrid.” This is not about simply adding BitTorrent and calling it a day. The point is to make the two delivery methods work together and complement each other:

  • The P2P network provides distributed acceleration. The more people download, the more peers join, and the faster the transfer becomes.
  • WebSeed/HTTP fallback guarantees availability, so downloads can still work in enterprise firewalls and internal network environments.
  • The control plane remains simple. We do not change the core logic of index.json; we only add a few optional metadata fields.

The real benefit is straightforward: users feel that “downloads are faster,” while the engineering team does not have to shoulder too much extra complexity. After all, the BT protocol is already mature, and there is little reason to reinvent the wheel.

Let us start with the overall architecture diagram to build a high-level mental model:

┌─────────────────────────────────────┐
│ Renderer (UI layer) │
├─────────────────────────────────────┤
│ IPC/Preload (bridge layer) │
├─────────────────────────────────────┤
│ VersionManager (version manager) │
├─────────────────────────────────────┤
│ HybridDownloadCoordinator (coord.) │
│ ├── DistributionPolicyEvaluator │
│ ├── DownloadEngineAdapter │
│ ├── CacheRetentionManager │
│ └── SHA256 Verifier │
├─────────────────────────────────────┤
│ WebTorrent (download engine) │
└─────────────────────────────────────┘

As the diagram shows, the system uses a layered design. The reason for separating responsibilities this clearly is simple: testability and replaceability.

  • The UI layer is responsible for displaying download progress and the sharing acceleration toggle. It is the surface.
  • The coordination layer is the core. It contains policy evaluation, engine adaptation, cache management, and integrity verification.
  • The engine layer encapsulates the concrete download implementation. At the moment, it uses WebTorrent.

The engine layer is abstracted behind the DownloadEngineAdapter interface. If we ever want to swap in a different BT engine later, or move the implementation into a sidecar process, that becomes much easier.

Separation of Control Plane and Data Plane

Section titled “Separation of Control Plane and Data Plane”

HagiCode Desktop keeps index.json as the sole control plane, and that design is critical. The control plane is responsible for version discovery, channel selection, and centralized policy, while the data plane is where the actual file transfer happens.

The new fields added to index.json are optional:

{
"asset": {
"torrentUrl": "https://cdn.example.com/app.torrent",
"infoHash": "abc123...",
"webSeeds": [
"https://cdn.example.com/app.zip",
"https://backup.example.com/app.zip"
],
"sha256": "def456...",
"directUrl": "https://cdn.example.com/app.zip"
}
}

All of these fields are optional. If they are missing, the client falls back to the traditional HTTP download mode. The advantage of this design is backward compatibility: older clients are completely unaffected.

Not every file is worth distributing through P2P.

DistributionPolicyEvaluator is responsible for evaluating the policy. Only files that meet all of the following conditions will use hybrid download:

  1. The source type must be an HTTP index: direct GitHub downloads or local folder sources do not use this path.
  2. The file size must be at least 100 MB: for smaller files, the overhead of P2P outweighs the benefit.
  3. Complete hybrid metadata must be present: torrentUrl, webSeeds, and sha256 are all required.
  4. Only the latest desktop package and web deployment package are eligible: historical versions continue to use the traditional distribution path.
class DistributionPolicyEvaluator {
evaluate(version: Version, settings: SharingAccelerationSettings): HybridDownloadPolicy {
// Check source type
if (version.sourceType !== 'http-index') {
return { useHybrid: false, reason: 'not-http-index' };
}
// Check metadata completeness
if (!version.hybrid) {
return { useHybrid: false, reason: 'not-eligible' };
}
// Check whether the feature is enabled
if (!settings.enabled) {
return { useHybrid: false, reason: 'shared-disabled' };
}
// Check asset type (latest desktop/web packages only)
if (!version.hybrid.isLatestDesktopAsset && !version.hybrid.isLatestWebAsset) {
return { useHybrid: false, reason: 'latest-only' };
}
return { useHybrid: true, reason: 'shared-enabled' };
}
}

This gives the system predictable behavior. Both developers and users can clearly understand which files will use P2P and which will not.

Let us start with the type definitions, because they form the foundation of the entire system.

// Hybrid distribution metadata
interface HybridDistributionMetadata {
torrentUrl?: string; // Torrent file URL
infoHash?: string; // InfoHash
webSeeds: string[]; // WebSeed list
sha256?: string; // File hash
directUrl?: string; // HTTP direct link (for origin fallback)
eligible: boolean; // Whether hybrid distribution is applicable
thresholdBytes: number; // Threshold in bytes
assetKind: VersionAssetKind;
isLatestDesktopAsset: boolean;
isLatestWebAsset: boolean;
}
// Sharing acceleration settings
interface SharingAccelerationSettings {
enabled: boolean; // Master switch
uploadLimitMbps: number; // Upload bandwidth limit
cacheLimitGb: number; // Cache limit
retentionDays: number; // Retention period
hybridThresholdMb: number; // Hybrid distribution threshold
onboardingChoiceRecorded: boolean;
}
// Download progress
interface VersionDownloadProgress {
current: number;
total: number;
percentage: number;
stage: VersionInstallStage; // queued, downloading, backfilling, verifying, extracting, completed, error
mode: VersionDownloadMode; // http-direct, shared-acceleration, source-fallback
peers?: number; // Number of connected peers
p2pBytes?: number; // Bytes received from P2P
fallbackBytes?: number; // Bytes received from fallback
verified?: boolean; // Whether verification has completed
}

Once the type system is clear, the rest of the implementation follows naturally.

HybridDownloadCoordinator orchestrates the entire download workflow. It coordinates policy evaluation, engine execution, SHA256 verification, and cache management.

class HybridDownloadCoordinator {
async download(
version: Version,
cachePath: string,
packageSource: PackageSource,
onProgress?: DownloadProgressCallback,
): Promise<HybridDownloadResult> {
// 1. Evaluate the policy: should hybrid download be used?
const policy = this.policyEvaluator.evaluate(version, settings);
// 2. Execute the download
if (policy.useHybrid) {
await this.engine.download(version, cachePath, settings, onProgress);
} else {
await packageSource.downloadPackage(version, cachePath, onProgress);
}
// 3. SHA256 verification (hard gate)
const verified = await this.verify(version, cachePath, onProgress);
if (!verified) {
await this.cacheRetentionManager.discard(version.id, cachePath);
throw new Error(`sha256 verification failed for ${version.id}`);
}
// 4. Mark as trusted cache and begin controlled seeding
await this.cacheRetentionManager.markTrusted({
versionId: version.id,
cachePath,
cacheSize,
}, settings);
return { cachePath, policy, verified };
}
}

There is one especially important point here: SHA256 verification is a hard gate. A downloaded file must pass verification before it can enter the installation flow. If verification fails, the cache is discarded to ensure that an incorrect file never causes installation problems.

DownloadEngineAdapter is an abstract interface that defines the methods every engine must implement:

interface DownloadEngineAdapter {
download(
version: Version,
destinationPath: string,
settings: SharingAccelerationSettings,
onProgress?: (progress: VersionDownloadProgress) => void,
): Promise<void>;
stopAll(): Promise<void>;
}

The V1 implementation is based on WebTorrent and is wrapped in InProcessTorrentEngineAdapter:

class InProcessTorrentEngineAdapter implements DownloadEngineAdapter {
async download(...) {
const client = this.getClient(settings); // Apply upload rate limiting
const torrent = client.add(torrentId, {
path: path.dirname(destinationPath),
destroyStoreOnDestroy: false,
maxWebConns: 8,
});
// Add WebSeed sources
torrent.on('ready', () => {
for (const seed of hybrid.webSeeds) {
torrent.addWebSeed(seed);
}
if (hybrid.directUrl) {
torrent.addWebSeed(hybrid.directUrl);
}
});
// Progress reporting - distinguish P2P from origin fallback
torrent.on('download', () => {
const hasP2PPeer = torrent.wires.some(w => w.type !== 'webSeed');
const mode = hasP2PPeer ? 'shared-acceleration' : 'source-fallback';
// ... report progress
});
}
}

A pluggable engine design makes future optimization much easier. For example, V2 could run the engine in a helper process to avoid bringing down the main process if the engine crashes.

At the UI layer, the thing users care about most is simple: “am I currently downloading through P2P or through HTTP fallback?” InProcessTorrentEngineAdapter determines that by checking the types inside torrent.wires:

const hasP2PPeer = torrent.wires.some((wire) => wire.type !== 'webSeed');
const hasFallbackWire = torrent.wires.some((wire) => wire.type === 'webSeed');
const mode = hasP2PPeer ? 'shared-acceleration'
: hasFallbackWire ? 'source-fallback'
: 'shared-acceleration';
const stage = hasP2PPeer ? 'downloading'
: hasFallbackWire ? 'backfilling'
: 'downloading';

The logic looks simple, but it is a key part of the user experience. Users can clearly see whether the current state is “sharing acceleration” or “origin backfilling,” which makes the behavior easier to understand.

Integrity verification uses Node.js’s crypto module to compute the hash in a streaming manner, which avoids loading the entire file into memory:

private async computeSha256(filePath: string): Promise<string> {
const hash = createHash('sha256');
await new Promise<void>((resolve, reject) => {
const stream = fs.createReadStream(filePath);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('error', reject);
stream.on('end', resolve);
});
return hash.digest('hex').toLowerCase();
}

This implementation is especially friendly for large files. Imagine downloading a 2 GB installation package and then trying to load the whole thing into memory just to verify it. Streaming solves that cleanly.

The full data flow looks like this:

┌────────────────────────────────────────────────────────────────────┐
│ User clicks install on a large-file version │
└────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────┐
│ VersionManager invokes the coordinator │
│ HybridDownloadCoordinator.download() │
└────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────┐
│ DistributionPolicyEvaluator.evaluate() │
│ Checks: source, metadata, switch, and asset type │
└────────────────────────────────────────────────────────────────────┘
┌───────────┴───────────┐
│ useHybrid? │
└───────────┬───────────┘
yes │ │ no
▼ ▼
┌──────────────────┐ ┌─────────────────────┐
│ P2P + WebSeed │ │ HTTP direct download│
│ Hybrid download │ │ (compatibility path)│
└──────────────────┘ └─────────────────────┘
┌──────────────────┐
│ SHA256 verify │
│ (hard gate) │
└────────┬─────────┘
┌────────┴─────────┐
│ Passed? │
└────────┬─────────┘
yes │ │ no
▼ ▼
┌────────────┐ ┌────────────────┐
│ Extract + │ │ Drop cache + │
│ install + │ │ return error │
│ seed safely│ └────────────────┘
└────────────┘

The flow is very clear end to end, and every step has a well-defined responsibility. When something goes wrong, it is much easier to pinpoint the failing stage.

Even the best technical design will fall flat if the user experience is poor. HagiCode Desktop invested a fair amount of effort in productizing this capability.

Most users do not know what BitTorrent or InfoHash means. So at the product level, we present the feature using the phrase “sharing acceleration”:

  • The feature is called “sharing acceleration,” not P2P download.
  • The setting is called “upload limit,” not seeding.
  • The progress label says “origin backfilling,” not WebSeed fallback.

This lowers the cognitive burden of the terminology and makes the feature easier to accept.

Enabled by Default in the First-Run Wizard

Section titled “Enabled by Default in the First-Run Wizard”

When new users launch the desktop app for the first time, they see a wizard page introducing sharing acceleration:

To improve download speed, we share the portions you have already downloaded with other users while your own download is in progress. This is completely optional, and you can turn it off at any time in Settings.

It is enabled by default, but users are given a clear way to opt out. If enterprise users do not want it, they can simply disable it during onboarding.

The settings page exposes three tunable parameters:

ParameterDefaultDescription
Upload limit2 MB/sPrevents excessive upstream bandwidth usage
Cache limit10 GBControls disk space consumption
Retention days7 daysAutomatically cleans old cache after this period

These parameters all have sensible defaults. Most users never need to change them, while advanced users can adjust them based on their own network environment.

Looking back at the overall solution, several design decisions are worth calling out.

Why not start with a sidecar or helper process right away? The reason is simple: ship quickly. An in-process design has a shorter development cycle and is easier to debug. The first priority is to get the feature running, then improve stability afterward.

Of course, this decision comes with a cost: if the engine crashes, it can affect the main process. We reduce that risk through adapter boundaries and timeout controls, and we also keep a migration path open so V2 can move into a separate process more easily.

We use SHA256 instead of MD5 or CRC32 because SHA256 is more secure. The collision cost for MD5 and CRC32 is too low. If someone maliciously crafted a fake installation package, the consequences could be severe. SHA256 costs more to compute, but the security gain is worth it.

Scenarios such as GitHub downloads and local folder sources do not use hybrid distribution. This is not a technical limitation; it is about avoiding unnecessary complexity. BT protocols add limited value inside private network scenarios and would only increase code complexity.

Inside SharingAccelerationSettingsStore, every numeric value must go through bounds checking and normalization:

private normalize(settings: SharingAccelerationSettings): SharingAccelerationSettings {
return {
enabled: Boolean(settings.enabled),
uploadLimitMbps: this.clampNumber(settings.uploadLimitMbps, 1, 200, DEFAULT_SETTINGS.uploadLimitMbps),
cacheLimitGb: this.clampNumber(settings.cacheLimitGb, 1, 500, DEFAULT_SETTINGS.cacheLimitGb),
retentionDays: this.clampNumber(settings.retentionDays, 1, 90, DEFAULT_SETTINGS.retentionDays),
hybridThresholdMb: DEFAULT_SETTINGS.hybridThresholdMb, // Fixed value, not user-configurable
onboardingChoiceRecorded: Boolean(settings.onboardingChoiceRecorded),
};
}
private clampNumber(value: number, min: number, max: number, fallback: number): number {
if (!Number.isFinite(value)) {
return fallback;
}
return Math.min(max, Math.max(min, Math.round(value)));
}

This prevents users from manually editing the configuration file into invalid values.

CacheRetentionManager.prune() is responsible for cleaning expired or oversized cache entries. The cleanup strategy uses LRU (least recently used):

const records = [...this.listRecords()]
.sort((left, right) =>
new Date(left.lastUsedAt).getTime() - new Date(right.lastUsedAt).getTime()
);
// When over the limit, evict the least recently used entries first
while (totalBytes > maxBytes && retainedEntries.length > 0) {
const evicted = records.find((record) => retainedEntries.includes(record.versionId));
retainedEntries.splice(retainedEntries.indexOf(evicted.versionId), 1);
removedEntries.push(evicted.versionId);
totalBytes -= evicted.cacheSize;
await fs.rm(evicted.cachePath, { force: true });
}

This logic ensures disk space is used efficiently while preserving historical versions that the user might still need.

When the user turns off sharing acceleration, the app must immediately stop seeding and destroy the torrent client:

async disableSharingAcceleration(): Promise<void> {
this.settingsStore.updateSettings({ enabled: false });
await this.cacheRetentionManager.stopAllSeeding(); // Stop seeding
await this.engine.stopAll(); // Destroy the torrent client
}

If a user disables the feature, the product should no longer consume any P2P resources. That is basic product etiquette.

There is no perfect solution, and hybrid distribution is no exception. These are the main trade-offs:

Crash isolation is weaker than a sidecar: V1 uses an in-process engine, so an engine crash can affect the main process. Adapter boundaries and timeout controls reduce the risk, but they are not a fundamental fix. V2 includes a planned migration path to a helper process.

Enabled-by-default resource usage: the default settings of 2 MB/s upload, 10 GB cache, and 7-day retention do consume some machine resources. User expectations are managed through onboarding copy and transparent settings.

Enterprise network compatibility: automatic WebSeed/HTTPS fallback preserves usability in enterprise networks, but it can reduce the acceleration gains from P2P. This is an intentional trade-off that prioritizes availability.

Backward-compatible metadata: all new fields are optional. If they are missing, the system falls back to HTTP mode. Older clients are completely unaffected, making upgrades smooth.

This article walked through the hybrid distribution architecture used in the HagiCode Desktop project. The key takeaways are:

  1. Layered architecture: the control plane and data plane are separated, and the engine is abstracted behind a pluggable interface for easier testing and extension.

  2. Policy-driven behavior: not every file uses P2P. Hybrid distribution is enabled only for large files that meet the required conditions.

  3. Integrity verification: SHA256 serves as a hard gate, and streaming verification avoids memory pressure.

  4. Productized presentation: BT terminology is hidden behind the phrase “sharing acceleration,” and the feature is enabled by default during onboarding.

  5. User control: upload limits, cache limits, retention days, and other parameters remain user-adjustable.

This architecture has already been implemented in the HagiCode Desktop project. If you try it out, we would love to hear your feedback after installation and real-world use.


If this article helped you:

Maybe we are all just ordinary people making our way through the world of technology, but that is fine. Ordinary people can still be persistent, and that persistence matters.

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

Running AI CLI Tools in Docker Containers: A Practical Guide to User Isolation and Persistent Volumes

Running AI CLI Tools in Docker Containers: A Practical Guide to User Isolation and Persistent Volumes

Section titled “Running AI CLI Tools in Docker Containers: A Practical Guide to User Isolation and Persistent Volumes”

Integrating AI coding tools like Claude Code, Codex, and OpenCode into containerized environments sounds simple, but there are hidden complexities everywhere. This article takes a deep dive into how the HagiCode project solves core challenges in Docker deployments, including user permissions, configuration persistence, and version management, so you can avoid the common pitfalls.

When we decided to run AI coding CLI tools inside Docker containers, the most intuitive thought was probably: “Aren’t containers just root? Why not install everything directly and call it done?” In reality, that seemingly simple idea hides several core problems that must be solved.

First, security restrictions are the first hurdle. Take Claude CLI as an example: it explicitly forbids running as the root user. This is a mandatory security check, and if root is detected, it refuses to start. You might think, can’t I just switch users with the USER directive? It is not that simple. There is still a mapping problem between the non-root user inside the container and the user permissions on the host machine.

Second, state persistence is the second trap. Claude Code requires login, Codex has its own configuration, and OpenCode also has a cache directory. If you have to reconfigure everything every time the container restarts, the whole idea of “automation” loses its meaning. We need these configurations to persist beyond the lifecycle of the container.

The third problem is permission consistency. Can processes inside the container access configuration files created by the host user? UID/GID mismatches often cause file permission errors, and this is extremely common in real deployments.

These problems may look independent, but in practice they are tightly connected. During HagiCode’s development, we gradually worked out a practical solution. Next, I will share the technical details and the lessons learned from those pitfalls.

The solution shared in this article comes from our practical experience in the HagiCode project. HagiCode is an open-source AI-assisted programming platform that integrates multiple mainstream AI coding assistants, including Claude Code, Codex, and OpenCode. As a project that needs cross-platform and highly available deployment, HagiCode has to solve the full range of challenges involved in containerized deployment.

If you find the technical solution in this article valuable, that is a sign HagiCode has something real to offer in engineering practice. In that case, the HagiCode official website and GitHub repository are both worth following.

There is a common misunderstanding here: Docker containers run as root by default, so why not just install the tools as root? If you think that way, Claude CLI will quickly teach you otherwise.

Terminal window
# Run Claude CLI directly as root? No.
docker run --rm -it --user root myimage claude
# Output: Error: This command cannot be run as root user

This is a hard security restriction in Claude CLI. The reason is simple: these CLI tools read and write sensitive user configuration, including API tokens, local caches, and even scripts written by the user. Running them with root privileges introduces too much risk.

So the question becomes: how can we satisfy the CLI’s security requirements while keeping container management flexible? We need to change the way we think about it: instead of switching users at runtime, create a dedicated user during the image build stage.

Creating a dedicated user: more than just changing a name

Section titled “Creating a dedicated user: more than just changing a name”

You might think that adding a single USER line to the Dockerfile is enough. That is indeed the simplest approach, but it is not robust enough.

HagiCode’s approach is to create a hagicode user with UID 1000, which usually matches the default user on most host machines:

RUN groupadd -o -g 1000 hagicode && \
useradd -o -u 1000 -g 1000 -s /bin/bash -m hagicode && \
mkdir -p /home/hagicode/.claude && \
chown -R hagicode:hagicode /home/hagicode

But this only solves the built-in user inside the image. What if the host user is UID 1001? You still need to support dynamic mapping when the container starts.

docker-entrypoint.sh contains the key logic:

Terminal window
if [ -n "$PUID" ] && [ -n "$PGID" ]; then
if ! id hagicode >/dev/null 2>&1; then
groupadd -g "$PGID" hagicode
useradd -u "$PUID" -g "$PGID" -s /bin/bash -m hagicode
fi
fi

The advantage of this design is clear: use the default UID 1000 at image build time, then adjust dynamically at runtime through the PUID and PGID environment variables. No matter what UID the host user has, ownership of configuration files remains correct.

The design philosophy of persistent volumes

Section titled “The design philosophy of persistent volumes”

Each AI CLI tool has its own preferred configuration directory, so they need to be mapped one by one:

CLI ToolPath in ContainerNamed Volume
Claude/home/hagicode/.claudeclaude-data
Codex/home/hagicode/.codexcodex-data
OpenCode/home/hagicode/.config/opencodeopencode-config-data

Why use named volumes instead of bind mounts? Three reasons:

  1. Simpler management: Named volumes are managed automatically by Docker, so you do not need to create host directories manually.
  2. Permission isolation: The initial contents of the volumes are created by the user inside the container, avoiding permission conflicts with the host.
  3. Independent migration: Volumes can exist independently of containers, so data is not lost when images are upgraded.

docker-compose-builder-web automatically generates the corresponding volume configuration:

volumes:
claude-data:
codex-data:
opencode-config-data:
services:
hagicode:
volumes:
- claude-data:/home/hagicode/.claude
- codex-data:/home/hagicode/.codex
- opencode-config-data:/home/hagicode/.config/opencode
user: "${PUID:-1000}:${PGID:-1000}"

Pay attention to the user field here: PUID and PGID are injected through environment variables to ensure that processes inside the container run with an identity that matches the host user. This detail matters because permission issues are painful to debug once they appear.

Version management: baked-in versions with runtime overrides

Section titled “Version management: baked-in versions with runtime overrides”

Pinning Docker image versions is essential for reproducibility. But in real development, we often need to test a newer version or urgently fix a bug. If we had to rebuild the image every time, the workflow would be far too inefficient.

HagiCode’s strategy is fixed versions as the default, with runtime overrides as an extension mechanism. It is a pragmatic engineering compromise between stability and flexibility.

Dockerfile.template pins versions here:

USER hagicode
WORKDIR /home/hagicode
# Configure the global npm install path
RUN mkdir -p /home/hagicode/.npm-global && \
npm config set prefix '/home/hagicode/.npm-global'
# Install CLI tools using pinned versions
RUN npm install -g @anthropic-ai/claude-code@2.1.71 && \
npm install -g @openai/codex@0.112.0 && \
npm install -g opencode-ai@1.2.25 && \
npm cache clean --force

docker-entrypoint.sh supports runtime overrides:

Terminal window
install_cli_override_if_needed() {
local package_name="$2"
local override_version="$5"
if [ -n "$override_version" ]; then
gosu hagicode npm install -g "${package_name}@${override_version}"
fi
}
# Example usage
install_cli_override_if_needed "" "@anthropic-ai/claude-code" "" "" "${CLAUDE_CODE_CLI_VERSION}"

This lets you test a new version through an environment variable without rebuilding the image:

Terminal window
docker run -e CLAUDE_CODE_CLI_VERSION=2.2.0 myimage

This design is practical because nobody wants to rebuild an image every time they test a new feature.

In addition to configuring CLI tools manually, some scenarios require automatic configuration injection. The most typical example is an API token.

Terminal window
if [ -n "$ANTHROPIC_AUTH_TOKEN" ]; then
mkdir -p /home/hagicode/.claude
cat > /home/hagicode/.claude/settings.json <<EOF
{
"env": {
"ANTHROPIC_AUTH_TOKEN": "${ANTHROPIC_AUTH_TOKEN}"
}
}
EOF
chown -R hagicode:hagicode /home/hagicode/.claude
fi

Two things matter here: pass sensitive information through environment variables instead of hard-coding it into the image, and make sure the ownership of configuration files is set correctly, otherwise the CLI tools will not be able to read them.

This is the easiest trap to fall into. The host user has UID 1001, while the container uses 1000, so files created on one side cannot be accessed on the other.

Terminal window
# Correct approach: make the container match the host user
docker run \
-e PUID=$(id -u) \
-e PGID=$(id -g) \
myimage

This issue is very common, and it can be frustrating the first time you run into it.

Configuration disappears after container restart

Section titled “Configuration disappears after container restart”

If you find yourself logging in again after every restart, check whether you forgot to mount a persistent volume:

volumes:
- claude-data:/home/hagicode/.claude

Nothing is more frustrating than carefully setting up a configuration only to see it disappear.

Do not run npm install -g directly inside a running container. The correct approaches are:

  1. Set an environment variable to trigger override installation.
  2. Or rebuild the image.
Terminal window
# Option 1: runtime override
docker run -e CLAUDE_CODE_CLI_VERSION=2.2.0 myimage
# Option 2: rebuild the image
docker build -t myimage:v2 .

There is more than one road to Rome, but some roads are smoother than others.

  • Pass API tokens through environment variables instead of writing them into the image.
  • Set configuration file permissions to 600.
  • Always run the application as a non-root user.
  • Update CLI versions regularly to fix security vulnerabilities.

Security is always important, but the real challenge is consistently enforcing it in practice.

If you want to support a new CLI tool in the future, there are only three steps:

  1. Dockerfile.template: add the installation step.
  2. docker-entrypoint.sh: add the version override logic.
  3. docker-compose-builder-web: add the persistent volume mapping.

This template-based design makes extension simple without changing the core logic.

Running AI CLI tools in Docker containers involves three core challenges: user permissions, configuration persistence, and version management. By combining dedicated users, named-volume isolation, and environment-variable-based overrides, the HagiCode project built a deployment architecture that is both secure and flexible.

Key design points:

  • User isolation: Create a dedicated user during the image build stage, with runtime support for dynamic PUID/PGID mapping.
  • Persistence strategy: Each CLI tool gets its own named volume, so restarts do not affect configuration.
  • Version flexibility: Fixed defaults ensure reproducibility, while runtime overrides provide room for testing.
  • Automated configuration: Sensitive configuration can be injected automatically through environment variables.

This solution has been running stably in the HagiCode project for some time, and I hope it offers useful reference points for developers with similar needs.

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

Technical Analysis of the HagiCode Soul Platform: The Evolution from Emerging Needs to an Independent Platform

Technical Analysis of the HagiCode Soul Platform: The Evolution from Emerging Needs to an Independent Platform

Section titled “Technical Analysis of the HagiCode Soul Platform: The Evolution from Emerging Needs to an Independent Platform”

Writing technical articles is not really such a grand thing. It is mostly just a matter of organizing the pitfalls you have run into and the detours you have taken. We have all been inexperienced before, after all. This article takes an in-depth look at the design philosophy, architectural evolution, and core technical implementation of Soul in the HagiCode project, and explores how an independent platform can provide a more focused experience for creating and sharing Agent personas.

In the practice of building AI Agents, we often run into a question that looks simple but is actually crucial: how do we give different Agents stable and distinctive language styles and personality traits?

It is a slightly frustrating question, honestly. In the early Hero system of HagiCode, different Heroes (Agent instances) were mainly distinguished through profession settings and generic prompts. That approach came with some fairly obvious pain points, and anyone who has tried something similar has probably felt the same.

First, language style was difficult to keep consistent. The same “developer engineer” role might sound professional and rigorous one day, then casual and loose the next. This was not a model problem so much as the absence of an independent personality configuration layer to constrain and guide the output style.

Second, the sense of character was generally weak. When we described an Agent’s traits, we often had to rely on vague adjectives like “friendly,” “professional,” or “humorous,” without concrete language rules to support those abstract descriptions. Put plainly, it sounded nice in theory, but there was little to hold onto in practice.

Third, persona configurations were almost impossible to reuse. Suppose we carefully designed the speaking style of a “catgirl waitress” and wanted to reuse that expression style in another business scenario. In practice, we would almost have to configure it again from scratch. Sometimes you do not want to possess something beautiful, only reuse it a little… and even that turns out to be hard.

To solve those real problems, we introduced the Soul mechanism: an independent language style configuration layer separate from equipment and descriptions. Soul can define an Agent’s speaking habits, tone preferences, and wording boundaries, can be shared and reused across multiple Heroes, and can also be injected into the system prompt automatically on the first Session call.

Some people might say that this is just configuring a few prompts. But sometimes the real question is not whether something can be done; it is how to do it more elegantly. As Soul matured, we realized it had enough depth to develop independently. A dedicated Soul platform could let users focus on creating, sharing, and browsing interesting persona configurations without being distracted by the rest of the Hero system. That is how the standalone platform at soul.hagicode.com came into being.

HagiCode is an open-source AI coding assistant project built with a modern technology stack and aimed at giving developers a smooth intelligent programming experience. The Soul platform approach shared in this article comes from our own hands-on exploration while building HagiCode to solve the practical problem of Agent persona management. If you find the approach valuable, then it probably means we have accumulated a certain amount of engineering judgment in practice, and the HagiCode project itself may also be worth a closer look.

The Technical Architecture Evolution of the Soul Platform

Section titled “The Technical Architecture Evolution of the Soul Platform”

The Soul platform did not appear all at once. It went through three clear stages. The story began abruptly and concluded naturally.

Phase 1: Soul Configuration Embedded in Hero

Section titled “Phase 1: Soul Configuration Embedded in Hero”

The earliest Soul implementation existed as a functional module inside the Hero workspace. We added an independent SOUL editing area to the Hero UI, supporting both preset application and text fine-tuning.

Preset application let users choose from classic persona templates such as “professional developer engineer” and “catgirl waitress.” Text fine-tuning let users personalize those presets further. On the backend, the Hero entity gained a Soul field, with SoulCatalogId used to identify its source.

This stage solved the question of whether the capability existed at all, and it grew forward somewhat awkwardly, like anything young does. But as Soul content became richer, the limitations of an architecture tightly coupled with the Hero system started to show.

To provide a better Soul discovery and reuse experience, we built a SOUL Marketplace catalog page with support for browsing, searching, viewing details, and favoriting.

At this stage, we introduced a combinatorial design built from 50 main Catalogs (base roles) and 10 orthogonal rules (expression styles). The main Catalogs defined the Agent’s core persona, with abstract character settings such as “Mistport Traveler” and “Night Hunter.” The orthogonal rules defined how the Agent expressed itself, with language style traits such as “Concise & Professional” and “Verbose & Friendly.”

50 x 10 = 500 possible combinations gave users a wide configuration space for personas. It is not an overwhelming number, but it is not small either. There are many roads to Rome, after all; some are simply easier to walk than others. On the backend, the full SOUL catalog was generated through catalog-sources.json, while the frontend presented those catalog entries as an interactive card list.

The in-site Marketplace was a good transitional solution, but only that: transitional. It was still attached to the main system, and for users who only wanted Soul functionality, the access path remained too deep. Not everyone wants to take the scenic route just to do something simple.

Phase 3: Splitting into an Independent Platform

Section titled “Phase 3: Splitting into an Independent Platform”

In the end, we decided to move Soul into an independent repository (repos/soul). The Marketplace in the original main system was changed into an external jump guide, while the new platform adopted a Builder-first design philosophy: the homepage is the creation workspace by default, so users can start building their own persona configuration the moment they open the site.

The technology stack was also comprehensively upgraded in this stage: Vite 8 + React 19 + TypeScript 5.9, a unified design language through the shadcn/ui component system, and Tailwind CSS 4 theme variables. The improvement in frontend engineering laid a solid foundation for future feature iteration.

Everything faded away… no, actually, everything was only just beginning.

One core design principle of the Soul platform is local-first. That means the homepage must remain fully functional without a backend, and failure to load remote materials must never block page entry.

There is nothing especially miraculous about that. It simply means thinking one step further when designing the system. Using a local snapshot as the baseline and remote data as enhancement lets the product remain basically usable under any network condition. Concretely, we implemented a two-layer material architecture:

export async function loadBuilderMaterials(): Promise<BuilderMaterials> {
const localMaterials = createLocalMaterials(snapshot) // local baseline
try {
const inspirationFragments = await fetchMarketplaceItems() // remote enhancement
return { ...localMaterials, inspirationFragments, remoteState: "ready" }
} catch (error) {
return { ...localMaterials, remoteState: "fallback" } // graceful degradation
}
}

Local materials come from build-time snapshots of the main system documentation and include the complete data for 50 base roles and 10 expression rules. Remote materials come from Souls published by users and fetched through the Marketplace API. Together, they give users a full spectrum of materials, from official templates to community creativity. If that sounds dramatic, it really is just local plus remote.

The core data abstraction of Soul is the SoulFragment:

export type SoulFragment = {
fragmentId: string
group: "main-catalog" | "expression-rule" | "published-soul"
title: string
summary: string
content: string
keywords: string[]
localized?: Partial<Record<AppLocale, LocalizedFragmentContent>>
sourceRef: SoulFragmentSourceRef
meta: SoulFragmentMeta
}

The group field distinguishes fragment types: the main catalog defines the character core, orthogonal rules define expression style, and user-published Souls are marked as published-soul. The localized field supports multilingual presentation, allowing the same fragment to display different titles and descriptions in different language environments. Internationalization is something you really want to think about early, and in this case we actually did.

The Builder draft state encapsulates the user’s current editing state:

export type SoulBuilderDraft = {
draftId: string
name: string
selectedMainFragmentId: string | null
selectedRuleFragmentId: string | null
inspirationSoulId: string | null
mainSlotText: string
ruleSlotText: string
customPrompt: string
previewText: string
updatedAt: string
}

Each fragment selected in the editor has its content concatenated into the corresponding slot, forming the final preview text. mainSlotText corresponds to the main role content, ruleSlotText corresponds to the expression rule content, and customPrompt is the user’s additional instruction text.

Preview compilation is the core capability of Soul Builder. It assembles user-selected fragments and custom text into a system prompt that can be copied directly:

export function compilePreview(
draft: Pick<SoulBuilderDraft, "mainSlotText" | "ruleSlotText" | "customPrompt">,
fragments: {
mainFragment: SoulFragment | null
ruleFragment: SoulFragment | null
inspirationFragment: SoulFragment | null
}
): PreviewCompilation {
// Assembly logic: main role + expression rule + inspiration reference + custom content
}

The compilation result is shown in the central preview panel, where users can see the final effect in real time and copy it to the clipboard with one click. It sounds simple, and it is. But simple things are often the most useful.

Frontend state management in Soul Builder follows one important principle: clear separation of state boundaries. More specifically, drawer state is not persisted and does not write directly into the draft. Only explicit Builder actions trigger meaningful state changes.

// Domain state (useSoulBuilder)
export function useSoulBuilder() {
// Material loading and caching
// Slot aggregation and preview compilation
// Copy actions and feedback messages
// Locale-safe descriptors
}
// Presentation state (useHomeEditorState)
export function useHomeEditorState() {
// activeSlot, drawerSide, drawerOpen
// default focus behavior
}

That separation ensures both edit-state safety and responsive UI behavior. Opening and closing the drawer is purely a UI interaction and should not trigger complicated persistence logic. It may sound obvious, but it matters: UI state and business state should be separated clearly so interface interactions do not pollute the core data model.

Soul Builder uses a single-drawer mode: only one slot drawer may be open at a time. Clicking the mask, pressing the ESC key, or switching slots automatically closes the current drawer. This simplifies state management and also matches common drawer interaction patterns on mobile.

Closing the drawer does not clear the current editing content, so when users come back, their context is preserved. This kind of “lightweight” drawer design avoids interrupting the user’s flow. Nobody wants carefully written content to disappear because of one accidental click.

Internationalization is an important capability of the Soul platform. System copy fully supports bilingual switching, while user draft text is never rewritten when the language changes, because draft text is user-authored free input rather than system-translated content.

Official inspiration cards (Marketplace Souls) keep the upstream display name while also providing a best-effort English summary. For Souls with Chinese names, we generate English versions through predefined mapping rules:

// English name mapping for main roles
const mainNameEnglishMap = {
"雾港旅人": "Mistport Traveler",
"夜航猎手": "Night Hunter",
// ...
}
// English name mapping for orthogonal rules
const ruleNameEnglishMap = {
"简洁干练": "Concise & Professional",
"啰嗦亲切": "Verbose & Friendly",
// ...
}

The mapping table itself looks simple enough, but keeping it in good shape still takes care. There are 50 main roles and 10 orthogonal rules, which means 500 combinations in total. That is not huge, but it is enough to deserve respect.

Bulk generation of the Soul Catalog happens on the backend, where C# is used to automate the creation of 50 x 10 = 500 combinations:

foreach (var main in source.MainCatalogs)
{
foreach (var orthogonal in source.OrthogonalCatalogs)
{
var catalogId = $"soul-{main.Index:00}-{orthogonal.Index:00}";
var displayName = BuildNickname(main, orthogonal);
var soulSnapshot = BuildSoulSnapshot(main, orthogonal);
// Write to the database...
}
}

The nickname generation algorithm combines the main role name with the expression rule name to create imaginative Agent codenames:

private static readonly string[] MainHandleRoots = [
"雾港", "夜航", "零帧", "星渊", "霓虹", "断云", ...
];
private static readonly string[] OrthogonalHandleSuffixes = [
"旅人", "猎手", "术师", "行者", "星使", ...
];
// Combination examples: 雾港旅人, 夜航猎手, 零帧术师...

Soul snapshot assembly follows a fixed template format that combines the main role core, signature traits, expression rule core, and output constraints together:

private static string BuildSoulSnapshot(main, orthogonal) => string.Join('\n', [
$"你的人设内核来自「{main.Name}」:{main.Core}",
$"保持以下标志性语言特征:{main.Signature}",
$"你的表达规则来自「{orthogonal.Name}」:{orthogonal.Core}",
$"必须遵循这些输出约束:{orthogonal.Signature}"
]);

Template assembly may sound terribly dull, but without that sort of dull work, interesting products rarely appear.

After splitting Soul from the main system into an independent platform, one important challenge was handling existing user data. It is a familiar problem: splitting things apart is easy, migration is not. We adopted three safeguards:

Backward compatibility protection. Previously saved Hero SOUL snapshots remain visible, and historical snapshots can still be previewed even if they no longer have a Marketplace source ID. In other words, none of the user’s prior configurations are lost; only where they appear has changed.

Main system API deprecation. The in-site Marketplace API returns HTTP status 410 Gone together with a migration notice that guides users to soul.hagicode.com.

Hero SOUL form refactoring. A migration notice block was added to the Hero Soul editing area to clearly tell users that the Soul platform is now independent and to provide a one-click jump button:

HeroSoulForm.tsx
<div className="rounded-2xl border border-orange-200/70 bg-orange-50/80 p-4">
<div>{t('hero.soul.migrationTitle')}</div>
<p>{t('hero.soul.migrationDescription')}</p>
<Button onClick={onOpenSoulPlatform}>
{t('hero.soul.openSoulPlatformAction')}
</Button>
</div>

Looking back at the development of the Soul platform as a whole, there are a few practical lessons worth sharing. They are not grand principles, just things learned from real mistakes.

Local-first runtime assumptions. When designing features that depend on remote data, always assume the network may be unavailable. Using local snapshots as the baseline and remote data as enhancement ensures the product remains basically usable under any network condition.

Clear separation of state boundaries. UI state and business state should be distinguished clearly so interface interactions do not pollute the core data model. Drawer toggles are purely UI state and should not be mixed with draft persistence.

Design for internationalization early. If your product has multilingual requirements, it is best to think about them during the data model design phase. The localized field adds some structural complexity, but it greatly reduces the long-term maintenance cost of multilingual content.

Automate the material synchronization workflow. Local materials for the Soul platform come from the main system documentation. When upstream documentation changes, there needs to be a mechanism to sync it into frontend snapshots. We designed the npm run materials:sync script to automate that process and keep materials aligned with upstream.

Based on the current architecture, the Soul platform could move in several directions in the future. These are only tentative ideas, but perhaps they can be useful as a starting point.

Community sharing ecosystem. Support user uploads and sharing of custom Souls, with rating, commenting, and recommendation mechanisms so excellent Soul configurations can be discovered and reused by more people.

Multimodal expansion. Beyond text style, the platform could also support dimensions such as voice style configuration, emoji usage preferences, and code style and formatting rules. It sounds attractive in theory; implementation may tell a more complicated story.

Intelligent assistance. Automatically recommend Souls based on usage scenarios, support style transfer and fusion, and even run A/B tests on the real-world effectiveness of different Souls. There is no better way to know than to try.

Cross-platform synchronization. Support importing persona configurations from other AI platforms, provide a standardized Soul export format, and integrate with mainstream Agent frameworks.

This article shares the full evolution of the HagiCode Soul platform from its earliest emerging need to an independent platform. We discussed why a Soul mechanism is needed to solve Agent persona consistency, analyzed the three stages of architectural evolution (embedded configuration, in-site Marketplace, and independent platform), examined the core data model, state management, preview compilation, and internationalization design in depth, and summarized practical migration lessons.

The essence of Soul is an independent persona configuration layer separated from business logic. It makes the language style of AI Agents definable, reusable, and shareable. From a technical perspective, the design itself is not especially complicated, but the problem it solves is real and broadly relevant.

If you are also building AI Agent products, it may be worth asking whether your persona configuration solution is flexible enough. The Soul platform’s practical experience may offer a few useful ideas.

Perhaps one day you will run into a similar problem as well. If this article can help a little when that happens, that is probably enough.


If you found this article helpful, feel free to give the project a Star on GitHub. The public beta has already started, and you are welcome to install it and try it out.

Thank you for reading. If you found this article useful, likes, bookmarks, and shares are all appreciated. This content was created with AI-assisted collaboration, and the final content was reviewed and confirmed by the author.