Skip to content

Architecture Overview

System Diagram

Claude Chat (mobile/web)
  → HTTPS (MCP Streamable HTTP + OAuth 2.1)
  → Traefik / Caddy (TLS termination)
  → Herald (Go binary, port 8420)
    ├── MCP Handler (/mcp)
    ├── OAuth 2.1 Server (PKCE, token rotation)
    ├── Task Manager (goroutine pool, priority queue)
    ├── Claude Code Executor (os/exec, stream-json parsing)
    ├── SQLite (persistence)
    └── MCP Notifications (server push via SSE)

Claude Code (terminal, reverse flow)
  → herald_push MCP tool
  → Herald (Go binary, port 8420)
    ├── Task Manager (creates linked task)
    └── SQLite (persistence)
  → Claude Chat can later resume via list_tasks + start_task

Design Principles

Single Binary

Everything is embedded in one Go executable (~15MB). No Docker, no runtime dependencies, no node_modules.

Async-First

Every Claude Code task runs in its own goroutine, managed by a bounded worker pool. The MCP protocol follows a start/poll/result pattern — the client starts a task and checks back later. The bridge is bidirectional: Claude Code can also push sessions to Herald via herald_push, creating linked tasks for remote continuation.

Stateless MCP, Stateful Backend

Each MCP request is independent. Herald doesn't rely on MCP session state. The actual state (tasks, tokens, history) lives in SQLite and in-memory structures.

Fail-Safe

If Herald crashes, running Claude Code processes continue (they're independent OS processes). Results are persisted to disk. On restart, Herald recovers state from SQLite.

Minimal Complexity

No premature abstractions. If an interface has only one implementation, it might not need to be an interface yet. Herald is a tool, not a framework.

Component Architecture

cmd/herald (wiring)
  └── internal/mcp        → internal/task, internal/project, internal/auth
  └── internal/task        → internal/executor, internal/store, internal/notify
  └── internal/executor    → (os/exec, nothing internal)
  └── internal/store       → (modernc.org/sqlite, nothing internal)
  └── internal/notify      → (net/http, nothing internal)

Each internal/ package is autonomous and communicates with others through interfaces. Dependency injection happens in cmd/herald/main.go only.

Key Components

Component Package Responsibility
MCP Server internal/mcp Handles MCP Streamable HTTP requests, registers tools
Task Manager internal/task Task lifecycle, priority queue, goroutine pool
Executor internal/executor Spawns Claude Code via os/exec, parses stream-json output
Store internal/store SQLite persistence — tasks, tokens, audit log
Auth internal/auth OAuth 2.1 server with PKCE, JWT tokens, token rotation
Notify internal/notify MCP push notifications (server-initiated via SSE)
Project internal/project Project configuration, validation, Git status
Config internal/config YAML loading, env var expansion, defaults

Key Interfaces

// store.Store — persistence layer
type Store interface {
    CreateTask(t *TaskRecord) error
    GetTask(id string) (*TaskRecord, error)
    UpdateTask(t *TaskRecord) error
    ListTasks(f TaskFilter) ([]TaskRecord, error)
}

// executor.Executor — task execution
type Executor interface {
    Execute(ctx context.Context, req Request, onProgress ProgressFunc) (*Result, error)
}

// notify.Notifier — notification delivery
type Notifier interface {
    Notify(event Event)
}

Interfaces are defined by their consumers, not their implementors. Small (1-3 methods).

Tech Stack

Component Choice Rationale
Language Go 1.26 Single binary, cross-compilation, goroutines, log/slog
MCP mcp-go Streamable HTTP, official MCP protocol support
Router chi Lightweight, stdlib net/http compatible
Database modernc.org/sqlite Pure Go SQLite, zero CGO
IDs google/uuid UUID generation
Config gopkg.in/yaml.v3 Standard YAML parsing
Testing testify Assertions (assert/require)
Logging log/slog (stdlib) Structured logging, no external dependency

6 direct dependencies. No ORM. No logging framework. No build toolchain.

Request Flow

MCP Tool Call

1. Claude Chat sends MCP request to /mcp
2. Traefik terminates TLS, forwards to 127.0.0.1:8420
3. Auth middleware validates Bearer token (JWT)
4. Rate limiter checks per-token quota
5. MCP server routes to tool handler (e.g., start_task)
6. Handler validates parameters
7. Task Manager creates task, enqueues
8. Worker pool picks up task, spawns Claude Code
9. Executor streams output, updates progress
10. On completion, result persisted to SQLite
11. MCP notifications pushed to Claude Chat via SSE

Claude Code Execution

1. Prompt written to {work_dir}/tasks/{task_id}/prompt.md
2. Executor runs: cat prompt.md | claude -p --output-format stream-json
3. Stream-json output parsed line by line
4. Progress events update task state in memory
5. Result event triggers completion
6. Exit code checked, task marked completed or failed

Long prompts

Prompts are always piped via stdin, never passed as CLI arguments. This avoids argument length limits and keeps prompts out of ps output.