This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Ech0 is a self-hosted personal microblog (timeline) platform. It is shipped as a single Go binary that serves both the REST API and the built SPA. Backend is Go 1.27+ (Gin + Wire DI + GORM + SQLite via CGO), frontend is Vue 3 + Vite + TypeScript + UnoCSS under web/. Two satellite web projects also live in-repo: hub/ (Vue 3 public-directory site) and site/ (React Router marketing/docs site) — they are independent of the Go binary.
For a full architecture walkthrough, read docs/dev/architecture-overview.md first — it covers the layered backend, business domains, Agent/MCP capability layers, event subsystem, infra modules, and pkg/ libraries end-to-end.
just is the repo's only task runner (there is no justfile). Root recipes cover the backend
and repo-wide chores; the sub-projects are just modules, so just web build runs inside
web/, just site dev inside site/, and so on. just --list web lists a module's recipes.
# Backend (repo root)
just run # ECH0_SERVER_MODE=debug go run ./cmd/ech0 serve (blocks on :6277)
just dev # Air hot-reload (auto-installs Air via `just air-install` if missing)
just build # go build -> ./bin/ech0 with version/commit injected
just test # go test ./...
just test-race # CGO_ENABLED=1 go test -race ./...
just test-cover # coverage, prints RAW + CALIBRATED totals
just lint # golangci-lint run
just fmt # golangci-lint fmt
just wire # regenerate internal/di/wire_gen.go (run after changing provider sets / DI graph)
just wire-check # fails if wire_gen.go is stale vs. wire.go
just mocks # regenerate testify mocks (mockery v3, pinned in justfile)
just mocks-check # fails if committed mocks are stale (also runs in CI)
just openapi # regenerate OpenAPI spec (Huma type-first) -> internal/openapi/openapi.yaml
just openapi-check # fails if the committed OpenAPI spec is stale vs. code (mirrors wire-check)
# Frontend module (equivalent pnpm scripts still work from web/)
just web install # pnpm install --frozen-lockfile
just web dev # Vite dev server on :5173, proxies to backend on :6277
just web build # type-check + vite build -> template/dist
just web test # vitest run
just web lint # eslint . --fix
just web lint-style # stylelint --fix (CSS/SCSS/<style> blocks in Vue SFCs)
just web format # prettier --write src/
just web i18n-check # key / unused / hardcoded / pseudo-smoke checks (required before PR)
# Other modules
just site build # docs/marketing site (React Router, ssr: false)
just hub build # public instance directory site
just docker build # container image (OS=/ARCH=/IMAGE_TAG= overridable)
# Full pre-PR verification (mandatory per CONTRIBUTING.md)
just check # SPDX + backend fmt/lint/openapi + web format/lint/style/i18n
# Single Go test
go test ./internal/middleware -run TestAuth # example
go test -run TestName ./path/to/pkg # by nameRun a single frontend test: pnpm -C web exec vitest run path/to/file.spec.ts (or -t "test name").
Binary entrypoint is cmd/ech0/main.go. CLI verbs (Cobra): ech0 serve (HTTP), bare ech0/ech0 tui (TUI), ech0 version, ech0 hello. Snapshot import/export is web-only (admin panel "数据管理"); there is no snapshot CLI verb.
Backend follows a strict layered architecture — handler → service → repository → database — with Google Wire generating the dependency graph. Each business domain (echo, comment, file, connect, user, auth, init, setting, embedding, copilot, dashboard, migrator, common) has parallel packages under internal/handler/<x>, internal/service/<x>, internal/repository/<x>, and internal/model/<x> (plus handler-only web and mcp). Note: internal/agent is not a layered domain — it is the LLM core package consumed by the copilot service.
internal/di/wire.godeclares provider sets (InfraSet,DomainSet,HandlerSet,EventSet,TaskerSet,MiddlewareSet,StorageSet,VisitorSet,RuntimeSet,AppSet) and theBuildAppinjector that composes the full runtime. If you add/remove a constructor or change a binding, runjust wirebefore committing.- Three stateful singletons must be injected once at the top level (
BuildApp/BuildServer) and shared down, or Wire silently builds a second copy and breaks things:visitor.Tracker(VisitorSet),storage.Manager(StorageSet),job.Manager(BuildJobManager). Thewire.gocomments explain each failure mode. - Cross-domain aliases are required when importing layers:
xxxHandler,xxxService,xxxRepository,xxxModel,xxxUtil(enforced by existing code; see README "Start Backend & Frontend" note). internal/appis a generic component lifecycle orchestrator.internal/serveris the thin Gin/HTTPComponentit manages. The other managed components arejob.Managerandtask.Manager(started in order job → task → server); theEventRegistrarregisters/drains subscriptions andsetting.Seedruns viaBeforeStart/AfterStophooks.internal/bootstrap/bootstrap.goruns before Cobra dispatches: loads config, initializes the zap-based logger, sets host env defaults. Config is accessed viaconfig.Config()(singleton).- HTTP routes use Huma (type-first OpenAPI) on top of Gin (
humaginadapter). JSON handlers are framework-neutral:func(ctx, *XxxInput) (commonModel.Result[T], error)that build the success envelope withcommonModel.OK(data[, msg])and return the raw service error — they don't importhumares/Huma. Each handler package declares itsXxxInput(path/query/header/body) andXxxOutput(=commonModel.Result[T]) types side by side. The single adapterhumares.Wraplocalizes the result + maps errors (humares.Err) and wraps it into Huma's outputEnvelope[T]; endpoints register viaroute(api, posture, op, handler)in per-domainregisterXxxfuncs, aggregated byregisterOperations(internal/router/huma.go). Per-endpoint auth is one posture —public()/optional(revoker)/secured(revoker, scopes...)[.audience(...)]— that emits both the OpenAPI Security declaration and the runtime middleware chain (existing gin middleware reused viahumares.Bridge), so authn/authz can't drift. The shared response contract (envelopecommonModel.Result[T], i18n error localization, security declarations) lives ininternal/handler/humares. The OpenAPI spec is generated from Go types (no annotations) — runtime docs at/api/docs, spec at/api/openapi.json|.yaml, committed copy atinternal/openapi/openapi.yaml. Non-JSON endpoints (SSE/WebSocket streams, multipart upload, binary download, OAuth redirects, captcha, cookie/token-issuance auth flows, MCP JSON-RPC) stay on raw gin (the parallelhandler/responsecontract:res.Execute→ samecommonModel.Resultwire shape) in thesetupXxxRoutesfunctions;SetupRouter(internal/router/router.go) wires the app explicitly.
Ech0 uses the in-repo Busen library (vendored at pkg/busen, imported as github.com/lin-snow/ech0/pkg/busen) as an async in-process event bus. The event system follows one rule: dependencies point inward to a pure vocabulary package. internal/event (package event) holds the event structs + their self-describing methods (EventName(), OrderingKey()) + WebhookObservation — it imports only domain models, never busen/services. internal/event/bus (alias eventbus) is the infrastructure: the *busen.Bus singleton, the generic Emit[T] (fire) / Notify[T] (best-effort fire + warn-log) / On[T] (type-routed subscribe) helpers, subscribe-option presets, and the EventRegistrar. The registrar wires all subscriptions on BeforeStart and tears them down on AfterStop, then drains any subscriber implementing Draining (e.g. the webhook dispatcher's worker pool) — there is no separate bus-drain component, and the bus's async queues are best-effort (dropped on shutdown). Subscribers live at internal/event/subscriber (agent processor, embedding processor, snapshot scheduler) and self-register via bus.On; the webhook dispatcher (internal/webhook) is itself a subscriber too — it bridges each observable event to a neutral WebhookObservation via bus.OnWithMeta (the metadata-aware variant of On). Routing is by Go type (no topic dimension); events self-describe their stable webhook name via EventName(). Producers publish with eventbus.Notify(ctx, bus, event.EchoCreated{...}) (best-effort; Emit when the caller wants the error) — there is no publisher facade. The bus decouples comment/echo/user events from side effects like webhooks, agent runs, and snapshots. Runtime tuning is via ECH0_EVENT_* env vars (buffers, parallelism, webhook worker pool) — see README "Event Runtime Parameters".
Webhook dispatch (internal/webhook) and the cache/index/snapshot processors (internal/event/subscriber) are implemented as event subscribers, not inline handler calls. When adding cross-cutting side effects, prefer publishing an event over invoking services directly from handlers. (Caution: internal/agent is not a subscriber — it is the synchronous LLM core; the bus-facing AgentProcessor subscriber only invalidates the AI-summary cache.)
Two function-calling integrations that point opposite ways:
internal/agent(outbound) — Ech0's LLM core. Collapses OpenAI-compatible + Anthropic protocols behind oneProviderabstraction (Complete/Stream) plus a ReAct tool loop (agent.Run). Zero domain deps: tools are injected by the caller asTool{Def, Execute}closures, and i18n strings are passed in (RunStrings). The copilot service (internal/service/copilot) injectssearch_echos/summarize_echos/stats_overview, then translates the loop'sAgentEventstream into Chat SSE (searching|sources|delta|done|error).Generateis the non-streaming entry used for summaries. SDK quirks (streaming tool-call fragment reassembly) are sealed inside each Provider; the loop only sees clean semantic events.internal/mcp(inbound) — a JSON-RPC 2.0 MCP server mounted at/mcp(RequireAuth) that exposes domain APIs to external LLMs as tools/resources. Each tool/resource declares a required scope at registration;Server.dispatchenforces it against the caller's access-token scopes before invoking the domain service viaAdapter. Keep authorization centralized in dispatch, not scattered into business code.
internal/storage is a unified abstraction over local disk and S3-compatible object stores, layered over the in-repo VireFS library (vendored at pkg/virefs, imported as github.com/lin-snow/ech0/pkg/virefs). Files are addressed by a flat key; schema.Resolve + PathPrefix map keys to on-disk paths or S3 object keys. Stored File.url is a snapshot of the UI-visible URL at write time. The /api/files static route serves local content; the stream routes are authenticated. S3SettingStore is bound to KeyValueRepository so S3 config lives in the settings DB, not env. Switching providers / migrating between local and S3 is documented at docs/usage/storage-migration.md.
- Vue 3 SFCs in
web/src, Pinia stores, Vue Router, i18n viavue-i18n, UnoCSS (Wind4 preset), markdown viamarkdown-it+ Vditor editor. - i18n guardrails in
web/scripts/(key completeness, unused keys, hardcoded strings, pseudo-locale smoke) are part ofjust check— do not introduce hardcoded UI strings; use translation keys. - Vite serves
:5173during dev and proxies/apito the backend on:6277.pnpm buildoutputs totemplate/dist/, which the Go binary embeds via//go:embed all:dist(template/template.go) and serves throughinternal/handler/webin production.
internal/config/config.go is the single config source; env vars are parsed via caarlos0/env. See .env.example for the full set (JWT secret, server port, DB path, log, S3, event runtime, etc.). Defaults target ./data/ for SQLite + uploaded files; Docker images mount /app/data.
- Before a PR:
just checkis mandatory (enforces backend lint, frontend lint, i18n checks).go build ./...andpnpm buildmust pass. Regenerate the OpenAPI spec (just openapi) whenever routes or request/response shapes change and commitinternal/openapi/openapi.yaml; CI-stylejust openapi-checkfails on drift. - DI changes: regenerate with
just wire; CI runsjust wire-check. - SPDX headers: every
.go/.ts/.vuefile needs an SPDX license header.just spdxadds missing ones; CI enforces viajust spdx-check. - Migrator (data portability): the admin panel's "数据管理" page wraps a bidirectional Migrator domain. Import supports Ech0 snapshot → Ech0 and Memos → Ech0; Export produces a unified Snapshot (a zip of
data/, seeinternal/migrator/snapshot) that round-trips back through theech0import. Core engine (importer/exporter execution, ETL, snapshot resource) lives ininternal/migrator;internal/service/migratoris a thin layer doing auth + job lifecycle + DTO + upload orchestration. Export triggers: manual snapshot (POST /migration/export, async viajob.Manager,TypeExport), scheduled snapshot (internal/task/scheduledcron, syncs through the exporter), and synchronous download (GET /migration/export/download). There is no separate "backup" concept — it is all snapshot export. - Integration comment endpoint:
POST /api/comments/integrationintentionally bypasses captcha/form-token — it requires an access token withcomment:writescope andintegrationaudience. Preserve this behavior. - Access tokens: scope/audience/
typdesign is documented atdocs/dev/access-token-scope-design.md; implementation is authoritative. - Layered import aliases (required):
xxxHandler,xxxService,xxxRepository,xxxModel,xxxUtil. - Logging: use the project zap wrapper at
internal/util/logwith amodulefield; seedocs/dev/logging.mdfor field conventions.
docs/dev/architecture-overview.md— start here: full architecture panorama (layers, domains, Agent/MCP, events, infra,pkg/)docs/dev/llm-chat-design.md,docs/dev/agent-toolcall-design.md— Chat RAG + Agent Provider/ReAct designdocs/dev/job-runner-design.md,docs/dev/snapshot-design.md— async job framework & snapshot exportdocs/dev/auth-design.md,docs/dev/access-token-scope-design.md— auth model & token scopesdocs/dev/i18n-contract.md— frontend/backend i18n contract (locale header, error field shapes, key naming)docs/dev/logging.md,docs/dev/timezone-design.md,docs/dev/table-design-standard.md— logging fields, TZ handling, admin table conventionsdocs/usage/storage-migration.md,docs/usage/mcp-usage.md,docs/usage/webhook-usage.md,docs/usage/capsule.md— operator/integration guidesdocs/dev/capsule/spec.md— Capsule interchange format + CLI (normative);docs/dev/capsule/capsule-design.md— why it is shaped that wayCONTRIBUTING.md— PR workflow and pre-submission checks;docs/README.md— doc index