Daemon CLI Tools

Development guide for background persistent CLI tools with HTTP API, covering process management, heartbeat, and daemon lifecycle.

Daemon CLI tools run persistently in the background, providing an HTTP API for CLI commands to interact with. They are ideal for scenarios requiring long-lived connections, expensive initialization, continuous task execution, or shared state.

Before reading this page, review the Common Standards that apply to all tool types.

Why Daemon Mode is Needed

Plain CLI follows a "run and exit" model, but some scenarios face fundamental limitations with this approach:

1. External Connections Require Persistence

Take the WeChat message agent (wechat_agent) as an example: the WeChat web protocol requires maintaining a long-lived connection to receive messages. If every message send required re-login and connection establishment, it would not only be slow (QR code scanning needed) but also trigger rate limiting from frequent logins. With Daemon mode, you log in once and the connection stays alive — subsequent operations reuse it.

2. Initialization Cost is Prohibitive

Some tools involve expensive startup: loading large configurations, establishing database connection pools, initializing SDKs, etc. If every invocation repeats this initialization, performance becomes unacceptable. The Daemon pays this cost once, and subsequent calls use the already-initialized resources.

3. Tasks Need Continuous Execution

Scheduled task runners (agent_cron) need to execute jobs in the background on a schedule, even after the user closes the terminal. This fundamentally requires a persistent process to host the scheduler.

4. Shared State and Resources

Multiple CLI invocations may need to share the same state (e.g., WeChat login session, loaded contact list). The Daemon serves as the central process holding this state, and all CLI invocations access it through the API, avoiding state synchronization issues.

In summary, consider Daemon mode when your tool needs any of the following:

  • Maintaining long-lived connections or external sessions
  • Expensive initialization that should be reused
  • Continuous background task execution
  • Shared state across multiple invocations

Architecture Overview

Daemon CLI uses a CLI + Background Process + HTTP API three-layer architecture:

┌──────────────────┐          HTTP API          ┌───────────────────┐
│   CLI Commands   │  ─────────────────────►   │   Daemon Process  │
│                  │                            │                   │
│  tool start      │  Start daemon              │  Runs in          │
│  tool status     │  Query status              │  background       │
│  tool stop       │  Stop daemon               │  HTTP API server  │
│  tool send       │  Business operations       │  Session/heartbeat│
└──────────────────┘          ◄────────────────  └───────────────────┘
                              JSON Response

Users and AI Agents still interact through CLI commands — the experience is no different from a Plain CLI. The difference is that CLI commands no longer execute business logic directly; instead, they forward requests to the background Daemon process, which executes them and returns results.

Project Structure

wechat_agent/
├── main.go
├── go.mod
├── cmd/
│   ├── root.go           # Command routing, --help / --skill
│   ├── skill.go          # skillSpec constant
│   ├── server.go         # Daemon startup & process management
│   ├── client.go         # CLI → daemon HTTP request wrapper
│   ├── send.go           # Send message (forwarded via API)
│   ├── contacts.go       # Query contacts (forwarded via API)
│   ├── status.go         # Query daemon status
│   ├── stop.go           # Stop daemon
│   ├── daemon_unix.go    # Unix process detachment
│   └── daemon_windows.go # Windows process detachment
└── internal/
    └── output/
        └── output.go

Daemon Lifecycle

Starting

Uses a "self-launch" pattern where the CLI starts a child process of itself and detaches immediately:

func startDaemon(dataDir string, port int) {
    exePath, _ := os.Executable()
    childArgs := []string{"start", "--foreground", "--port", strconv.Itoa(port), "--data-dir", dataDir}

    cmd := exec.Command(exePath, childArgs...)
    cmd.Stdout = logFile
    cmd.Stderr = logFile
    cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}

    cmd.Start()
    cmd.Process.Release()
}

Key points:

  • By default tool start launches in the background — the user's terminal is not blocked
  • The child process uses --foreground to actually run the service (for debugging or process manager scenarios)
  • Setsid: true detaches the process from the terminal session
  • Log output goes to a file, not the terminal

State Files

The Daemon shares connection information with CLI through the filesystem:

~/.my_tool/
├── daemon.pid        # Process PID — used to check if alive
├── daemon.port       # HTTP service port — CLI connects via this port
├── session.json      # Business session state (e.g., WeChat login)
└── daemon.log        # Runtime logs
func WriteDaemonPort(port int) error {
    return os.WriteFile(portFilePath(), []byte(strconv.Itoa(port)), 0644)
}

func ReadDaemonPort() (int, error) {
    data, err := os.ReadFile(portFilePath())
    if err != nil {
        return 0, err
    }
    return strconv.Atoi(strings.TrimSpace(string(data)))
}

func IsDaemonRunning() bool {
    pid, err := ReadPID()
    if err != nil {
        return false
    }
    return isProcessAlive(pid)
}

Stopping

var daemonStopCmd = &cobra.Command{
    Use:   "stop",
    Short: "Stop the running daemon",
    RunE: func(cmd *cobra.Command, args []string) error {
        if !session.IsDaemonRunning() {
            output.Print(GetJSONOutput(), map[string]interface{}{
                "message": "daemon not running",
            }, nil)
            return nil
        }

        resp, err := daemon.Stop()
        if err != nil {
            session.CleanDaemonFiles()
            output.Success(GetJSONOutput(), "daemon stopped")
            return nil
        }
        // ...
    },
}

Daemon HTTP API

The Daemon starts an HTTP server on a random local port, listening only on 127.0.0.1:

mux := http.NewServeMux()
mux.HandleFunc("/api/status", s.handleStatus)
mux.HandleFunc("/api/stop", s.handleStop)
mux.HandleFunc("/api/jobs/list", s.handleJobsList)

s.listener, _ = net.Listen("tcp", "127.0.0.1:0")
s.httpPort = s.listener.Addr().(*net.TCPAddr).Port
session.WriteDaemonPort(s.httpPort)

CLI commands connect to the Daemon by reading the port file:

func getServerPort(dataDir string) (int, error) {
    data, err := os.ReadFile(portFilePath(dataDir))
    if err != nil {
        return 0, fmt.Errorf("daemon not running (no port file)")
    }
    return strconv.Atoi(strings.TrimSpace(string(data)))
}

func apiGet(dataDir, path string) (*http.Response, error) {
    port, err := getServerPort(dataDir)
    if err != nil {
        return nil, err
    }
    url := fmt.Sprintf("http://127.0.0.1:%d%s", port, path)
    return http.Get(url)
}

Heartbeat Mechanism

For Daemons that maintain external connections (e.g., WeChat long-polling), heartbeats are essential to keep connections alive and handle exceptions:

bot.MessageErrorHandler = func(err error) error {
    log.Printf("[Heartbeat] Sync error: %v (will auto-retry)", err)
    return nil
}

Heartbeat design principles:

  • Errors should not terminate the process — log and auto-retry
  • Attempt to re-establish connections on disconnect
  • Provide status query endpoints so CLI and AI Agents can sense connection health

Daemon Subcommand Design

Use a daemon subcommand group (or top-level commands) to manage the daemon lifecycle:

daemonCmd = &cobra.Command{
    Use:   "daemon <start|status|stop>",
    Short: "Manage the daemon process",
}

daemonStartCmd = &cobra.Command{
    Use:   "start",
    Short: "Start the daemon",
}

daemonStatusCmd = &cobra.Command{
    Use:   "status",
    Short: "Show daemon status",
}

daemonStopCmd = &cobra.Command{
    Use:   "stop",
    Short: "Stop the running daemon",
}

func init() {
    rootCmd.AddCommand(daemonCmd)
    daemonCmd.AddCommand(daemonStartCmd)
    daemonCmd.AddCommand(daemonStatusCmd)
    daemonCmd.AddCommand(daemonStopCmd)

    daemonStartCmd.Flags().BoolVar(&foreground, "foreground", false,
        "Run daemon in foreground (blocks terminal)")
}

For tools like wechat_agent where the Daemon is the only operating mode, start / status / stop can be top-level commands:

wechat_agent start      # Start daemon (background by default)
wechat_agent status     # Query status
wechat_agent stop       # Stop daemon
wechat_agent send ...   # Business command (auto-forwarded to daemon)

--help for Daemon CLIs

Daemon CLI's --help needs to explain the architecture so users understand the runtime model:

fmt.Fprint(w, `wechat_agent - WeChat Message Assistant

Important: Get the complete usage guide first:
  wechat_agent --skill

Architecture:
  Uses daemon mode. The start command runs as a background daemon by default
  (unaffected by terminal closure). Then use contacts/send commands to operate.
  Sessions remain valid as long as the process is alive.

Usage:
  wechat_agent start     [flags]              Start the daemon
  wechat_agent contacts  [flags]              Get contact list
  wechat_agent send      [flags] "message"    Send a message
  wechat_agent status    [flags]              Check status
  wechat_agent stop      [flags]              Stop the daemon
`)

Real-World Example: agent_cron

agent_cron is a daemon-based cron scheduler that demonstrates the Daemon CLI pattern. It supports two types of periodic tasks:

  • Script tasks — execute a local script/binary on schedule
  • External tasks — submit a prompt to the WinClaw Local API on schedule

The daemon manages job scheduling, execution, and result persistence, while the CLI provides a familiar command-line interface for job management:

agent_cron daemon start                        # Start the scheduler daemon
agent_cron job add --type external_task \       # Add a scheduled AI task
  --name "morning-report" \
  --cron "0 0 9 * * 1-5" \
  --prompt "Summarize yesterday's work"
agent_cron job ls                              # List all jobs
agent_cron job run <id>                        # Trigger a job immediately
agent_cron job logs <id>                       # View execution history

The external task type uses the WinClaw Local API to send prompts to WinClaw for execution, making it possible to schedule AI-powered tasks that run automatically.

Development Checklist

  • All basic requirements from Common Standards
  • start / status / stop lifecycle management commands
  • --foreground flag (for debugging and process managers)
  • PID file and port file management
  • Process detachment (Unix: Setsid, Windows: CREATE_NEW_PROCESS_GROUP)
  • Log output to file
  • HTTP API listens only on 127.0.0.1
  • Heartbeat mechanism (if maintaining external connections)
  • Graceful shutdown (cleanup resources, remove state files)
  • --help explains Daemon architecture