Move completed plan documents to docs/plans/completed/ with concise completion summaries alongside the originals. Archived: MCP integration, Go API design/plan, CLI meta-package design. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
26 KiB
go-api Design — HTTP Gateway + OpenAPI SDK Generation
Date: 2026-02-20
Author: Virgil
Status: Phase 1 + Phase 2 + Phase 3 Complete (176 tests in go-api)
Module: forge.lthn.ai/core/go-api
Problem
The Core Go ecosystem exposes 42+ tools via MCP (JSON-RPC), which is ideal for AI agents but inaccessible to regular HTTP clients, frontend applications, and third-party integrators. There is no unified HTTP gateway, no OpenAPI specification, and no generated SDKs.
Both external customers (Host UK products) and Lethean network peers need programmatic access to the same services. The gateway also serves web routes, static assets, and streaming endpoints — not just REST APIs.
Solution
A go-api package that acts as the central HTTP gateway:
- Gin-based HTTP gateway with extensible middleware via gin-contrib plugins
- RouteGroup interface that subsystems implement to register their own endpoints (API, web, or both)
- WebSocket + SSE integration for real-time streaming
- OpenAPI 3.1 spec generation via runtime SpecBuilder (not swaggo annotations)
- SDK generation pipeline targeting 11 languages via openapi-generator-cli
Architecture
Four-Protocol Access
Same backend services, four client protocols:
┌─── REST (go-api) POST /v1/ml/generate → JSON
│
├─── GraphQL (gqlgen) mutation { mlGenerate(...) { response } }
Client ────────────┤
├─── WebSocket (go-ws) subscribe ml.generate → streaming
│
└─── MCP (go-ai) ml_generate → JSON-RPC
Dependency Graph
go-api (Gin engine + middleware + OpenAPI)
↑ imported by (each registers its own routes)
├── go-ai/api/ → /v1/file/*, /v1/process/*, /v1/metrics/*
├── go-ml/api/ → /v1/ml/*
├── go-rag/api/ → /v1/rag/*
├── go-agentic/api/ → /v1/tasks/*
├── go-help/api/ → /v1/help/*
└── go-ws/api/ → /ws (WebSocket upgrade)
go-api has zero internal ecosystem dependencies. Subsystems import go-api, not the other way round.
Subsystem Opt-In
Not every MCP tool becomes a REST endpoint. Each subsystem decides what to expose via a separate RegisterAPI() method, independent of MCP's RegisterTools(). A subsystem with 15 MCP tools might expose 5 REST endpoints.
Package Structure
forge.lthn.ai/core/go-api
├── api.go # Engine struct, New(), Serve(), Shutdown()
├── middleware.go # Auth, CORS, rate limiting, request logging, recovery
├── options.go # WithAddr, WithAuth, WithCORS, WithRateLimit, etc.
├── group.go # RouteGroup interface + registration
├── response.go # Envelope type, error responses, pagination
├── docs/ # Generated swagger docs (swaggo output)
├── sdk/ # SDK generation tooling / Makefile targets
└── go.mod # forge.lthn.ai/core/go-api
Core Interface
// RouteGroup registers API routes onto a Gin router group.
// Subsystems implement this to expose their endpoints.
type RouteGroup interface {
// Name returns the route group identifier (e.g. "ml", "rag", "tasks")
Name() string
// BasePath returns the URL prefix (e.g. "/v1/ml")
BasePath() string
// RegisterRoutes adds handlers to the provided router group
RegisterRoutes(rg *gin.RouterGroup)
}
// StreamGroup optionally declares WebSocket channels a subsystem publishes to.
type StreamGroup interface {
Channels() []string
}
Subsystem Example (go-ml)
// In go-ml/api/routes.go
package api
type Routes struct {
service *ml.Service
}
func NewRoutes(svc *ml.Service) *Routes {
return &Routes{service: svc}
}
func (r *Routes) Name() string { return "ml" }
func (r *Routes) BasePath() string { return "/v1/ml" }
func (r *Routes) RegisterRoutes(rg *gin.RouterGroup) {
rg.POST("/generate", r.Generate)
rg.POST("/score", r.Score)
rg.GET("/backends", r.Backends)
rg.GET("/status", r.Status)
}
func (r *Routes) Channels() []string {
return []string{"ml.generate", "ml.status"}
}
// @Summary Generate text via ML backend
// @Tags ml
// @Accept json
// @Produce json
// @Param input body MLGenerateInput true "Generation parameters"
// @Success 200 {object} Response[MLGenerateOutput]
// @Router /v1/ml/generate [post]
func (r *Routes) Generate(c *gin.Context) {
var input MLGenerateInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(400, api.Fail("invalid_input", err.Error()))
return
}
result, err := r.service.Generate(c.Request.Context(), input.Backend, input.Prompt, ml.GenOpts{
Temperature: input.Temperature,
MaxTokens: input.MaxTokens,
Model: input.Model,
})
if err != nil {
c.JSON(500, api.Fail("ml.generate_failed", err.Error()))
return
}
c.JSON(200, api.OK(MLGenerateOutput{
Response: result,
Backend: input.Backend,
Model: input.Model,
}))
}
Engine Wiring (in core CLI)
engine := api.New(
api.WithAddr(":8080"),
api.WithCORS("*"),
api.WithAuth(api.BearerToken(cfg.APIKey)),
api.WithRateLimit(100, time.Minute),
api.WithWSHub(wsHub),
)
engine.Register(mlapi.NewRoutes(mlService))
engine.Register(ragapi.NewRoutes(ragService))
engine.Register(agenticapi.NewRoutes(agenticService))
engine.Serve(ctx) // Blocks until context cancelled
Response Envelope
All endpoints return a consistent envelope:
type Response[T any] struct {
Success bool `json:"success"`
Data T `json:"data,omitempty"`
Error *Error `json:"error,omitempty"`
Meta *Meta `json:"meta,omitempty"`
}
type Error struct {
Code string `json:"code"`
Message string `json:"message"`
Details any `json:"details,omitempty"`
}
type Meta struct {
RequestID string `json:"request_id"`
Duration string `json:"duration"`
Page int `json:"page,omitempty"`
PerPage int `json:"per_page,omitempty"`
Total int `json:"total,omitempty"`
}
Helper functions:
func OK[T any](data T) Response[T]
func Fail(code, message string) Response[any]
func Paginated[T any](data T, page, perPage, total int) Response[T]
Middleware Stack
api.New(
api.WithAddr(":8080"),
api.WithCORS(api.CORSConfig{...}), // gin-contrib/cors
api.WithAuth(api.BearerToken("...")), // Phase 1: simple bearer token
api.WithRateLimit(100, time.Minute), // Per-IP sliding window
api.WithRequestID(), // X-Request-ID header generation
api.WithRecovery(), // Panic recovery → 500 response
api.WithLogger(slog.Default()), // Structured request logging
)
Auth evolution path: bearer token → API keys → Authentik (OIDC/forward auth). Middleware slot stays the same.
WebSocket Integration
go-api wraps the existing go-ws Hub as a first-class transport:
// Automatic registration:
// GET /ws → WebSocket upgrade (go-ws Hub)
// Client subscribes: {"type":"subscribe","channel":"ml.generate"}
// Events arrive: {"type":"event","channel":"ml.generate","data":{...}}
// Client unsubscribes: {"type":"unsubscribe","channel":"ml.generate"}
Subsystems implementing StreamGroup declare which channels they publish to. This metadata feeds into the OpenAPI spec as documentation.
OpenAPI + SDK Generation
Runtime Spec Generation (SpecBuilder)
swaggo annotations were rejected because routes are dynamic via RouteGroup, Response[T] generics break swaggo, and MCP tools already carry JSON Schema at runtime. Instead, a SpecBuilder constructs the full OpenAPI 3.1 spec from registered RouteGroups at runtime.
// Groups that implement DescribableGroup contribute endpoint metadata
type DescribableGroup interface {
RouteGroup
Describe() []RouteDescription
}
// SpecBuilder assembles the spec from all groups
builder := &api.SpecBuilder{Title: "Core API", Description: "...", Version: "1.0.0"}
spec, _ := builder.Build(engine.Groups())
MCP-to-REST Bridge (ToolBridge)
The ToolBridge converts MCP tool descriptors into REST POST endpoints and implements both RouteGroup and DescribableGroup. Each tool becomes POST /{tool_name}. Generic types are captured at MCP registration time via closures, enabling JSON unmarshalling to the correct input type at request time.
bridge := api.NewToolBridge("/v1/tools")
mcp.BridgeToAPI(mcpService, bridge) // Populates bridge from MCP tool registry
engine.Register(bridge) // Registers REST endpoints + OpenAPI metadata
Swagger UI
// Built-in at GET /swagger/*any
// SpecBuilder output served via gin-swagger, cached via sync.Once
api.New(api.WithSwagger("Core API", "...", "1.0.0"))
SDK Generation
# Via openapi-generator-cli (11 languages supported)
core api sdk --lang go # Generate Go SDK
core api sdk --lang typescript-fetch,python # Multiple languages
core api sdk --lang rust --output ./sdk/ # Custom output dir
CLI Commands
core api spec # Emit OpenAPI JSON to stdout
core api spec --format yaml # YAML variant
core api spec --output spec.json # Write to file
core api sdk --lang python # Generate Python SDK
core api sdk --lang go,rust # Multiple SDKs
Dependencies
| Package | Purpose |
|---|---|
github.com/gin-gonic/gin |
HTTP framework |
github.com/swaggo/gin-swagger |
Swagger UI middleware |
github.com/gin-contrib/cors |
CORS middleware |
github.com/gin-contrib/secure |
Security headers |
github.com/gin-contrib/sessions |
Server-side sessions |
github.com/gin-contrib/authz |
Casbin authorisation |
github.com/gin-contrib/httpsign |
HTTP signature verification |
github.com/gin-contrib/slog |
Structured request logging |
github.com/gin-contrib/timeout |
Per-request timeouts |
github.com/gin-contrib/gzip |
Gzip compression |
github.com/gin-contrib/static |
Static file serving |
github.com/gin-contrib/pprof |
Runtime profiling |
github.com/gin-contrib/expvar |
Runtime metrics |
github.com/gin-contrib/location/v2 |
Reverse proxy detection |
github.com/99designs/gqlgen |
GraphQL endpoint |
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin |
Distributed tracing |
gopkg.in/yaml.v3 |
YAML spec export |
forge.lthn.ai/core/go-ws |
WebSocket Hub (existing) |
Estimated Size
| Component | LOC |
|---|---|
| Engine + options | ~200 |
| Middleware | ~150 |
| Response envelope | ~80 |
| RouteGroup interface | ~30 |
| WebSocket integration | ~60 |
| Tests | ~300 |
| Total go-api | ~820 |
Each subsystem's api/ package adds ~100-200 LOC per route group.
Phase 1 — Implemented (20 Feb 2026)
Commit: 17ae945 on Forge (core/go-api)
| Component | Status | Tests |
|---|---|---|
| Response envelope (OK, Fail, Paginated) | Done | 9 |
| RouteGroup + StreamGroup interfaces | Done | 4 |
| Engine (New, Register, Handler, Serve) | Done | 9 |
| Bearer auth middleware | Done | 3 |
| Request ID middleware | Done | 2 |
| CORS middleware (gin-contrib/cors) | Done | 3 |
| WebSocket endpoint | Done | 3 |
| Swagger UI (gin-swagger) | Done | 2 |
| Health endpoint | Done | 1 |
| Total | ~840 LOC | 36 |
Integration proof: go-ml/api/ registers 3 endpoints with 12 tests (0c23858).
Phase 2 Wave 1 — Implemented (20 Feb 2026)
Commits: 6bb7195..daae6f7 on Forge (core/go-api)
| Component | Option | Dependency | Tests |
|---|---|---|---|
| Authentik (forward auth + OIDC) | WithAuthentik() |
go-oidc/v3, oauth2 |
14 |
| Security headers (HSTS, CSP, etc.) | WithSecure() |
gin-contrib/secure |
8 |
| Structured request logging | WithSlog() |
gin-contrib/slog |
6 |
| Per-request timeouts | WithTimeout() |
gin-contrib/timeout |
5 |
| Gzip compression | WithGzip() |
gin-contrib/gzip |
5 |
| Static file serving | WithStatic() |
gin-contrib/static |
5 |
| Wave 1 Total | 43 |
Cumulative: 76 tests (36 Phase 1 + 43 Wave 1 - 3 shared), all passing.
Phase 2 Wave 2 — Implemented (20 Feb 2026)
Commits: 64a8b16..67dcc83 on Forge (core/go-api)
| Component | Option | Dependency | Tests | Notes |
|---|---|---|---|---|
| Brotli compression | WithBrotli() |
andybalholm/brotli |
5 | Custom middleware; gin-contrib/brotli is empty stub |
| Response caching | WithCache() |
none (in-memory) | 5 | Custom middleware; gin-contrib/cache is per-handler, not global |
| Server-side sessions | WithSessions() |
gin-contrib/sessions |
5 | Cookie store, configurable name + secret |
| Casbin authorisation | WithAuthz() |
gin-contrib/authz, casbin/v2 |
5 | Subject via Basic Auth; RBAC policy model |
| Wave 2 Total | 20 |
Cumulative: 102 passing tests (2 integration skipped), all green.
Phase 2 Wave 3 — Implemented (20 Feb 2026)
Commits: 7b3f99e..d517fa2 on Forge (core/go-api)
| Component | Option | Dependency | Tests | Notes |
|---|---|---|---|---|
| HTTP signature verification | WithHTTPSign() |
gin-contrib/httpsign |
5 | HMAC-SHA256; extensible via httpsign.Option |
| Server-Sent Events | WithSSE() |
none (custom SSEBroker) | 6 | Channel filtering, multi-client broadcast, GET /events |
| Reverse proxy detection | WithLocation() |
gin-contrib/location/v2 |
5 | X-Forwarded-Host/Proto parsing |
| Locale detection | WithI18n() |
golang.org/x/text/language |
5 | Accept-Language parsing, message lookup, GetLocale/GetMessage |
| GraphQL endpoint | WithGraphQL() |
99designs/gqlgen |
5 | /graphql + optional /graphql/playground |
| Wave 3 Total | 26 |
Cumulative: 128 passing tests (2 integration skipped), all green.
Phase 2 Wave 4 — Implemented (21 Feb 2026)
Commits: 32b3680..8ba1716 on Forge (core/go-api)
| Component | Option | Dependency | Tests | Notes |
|---|---|---|---|---|
| Runtime profiling | WithPprof() |
gin-contrib/pprof |
5 | /debug/pprof/* endpoints, flag-based mount |
| Runtime metrics | WithExpvar() |
gin-contrib/expvar |
5 | /debug/vars endpoint, flag-based mount |
| Distributed tracing | WithTracing() |
otelgin + OpenTelemetry SDK |
5 | W3C traceparent propagation, span attributes |
| Wave 4 Total | 15 |
Cumulative: 143 passing tests (2 integration skipped), all green.
Phase 2 complete. All 4 waves implemented. Every planned plugin has a With*() option and tests.
Phase 3 — OpenAPI Spec Generation + SDK Codegen (21 Feb 2026)
Architecture: Runtime OpenAPI generation via SpecBuilder (NOT swaggo annotations). Routes are dynamic via RouteGroup, Response[T] generics break swaggo, and MCP tools carry JSON Schema at runtime. A ToolBridge converts tool descriptors into RouteGroup + OpenAPI metadata. A SpecBuilder constructs the full OpenAPI 3.1 spec. SDK codegen wraps openapi-generator-cli.
Wave 1: go-api (Tasks 1-5)
Commits: 465bd60..1910aec on Forge (core/go-api)
| Component | File | Tests | Notes |
|---|---|---|---|
| DescribableGroup interface | group.go |
5 | Opt-in OpenAPI metadata for RouteGroups |
| ToolBridge | bridge.go |
6 | Tool descriptors → POST endpoints + DescribableGroup |
| SpecBuilder | openapi.go |
6 | OpenAPI 3.1 JSON with Response[T] envelope wrapping |
| Swagger refactor | swagger.go |
5 | Replaced hardcoded empty spec with SpecBuilder |
| Spec export | export.go |
5 | JSON + YAML export to file/writer |
| SDK codegen | codegen.go |
5 | 11-language wrapper for openapi-generator-cli |
| Wave 1 Total | 32 |
Wave 2: go-ai MCP bridge (Tasks 6-7)
Commits: 2107eda..c37e1cf on Forge (core/go-ai)
| Component | File | Tests | Notes |
|---|---|---|---|
| Tool registry | mcp/registry.go |
5 | Generic addToolRecorded[In,Out] captures types in closures |
| BridgeToAPI | mcp/bridge.go |
5 | MCP tools → go-api ToolBridge, 10MB body limit, error classification |
| Wave 2 Total | 10 |
Wave 3: CLI commands (Tasks 8-9)
Commit: d6eec4d on Forge (core/cli dev branch)
| Component | File | Tests | Notes |
|---|---|---|---|
core api spec |
cmd/api/cmd_spec.go |
2 | JSON/YAML export, --output/--format flags |
core api sdk |
cmd/api/cmd_sdk.go |
2 | --lang (required), --output, --spec, --package flags |
| Wave 3 Total | 4 |
Cumulative go-api: 176 passing tests. Phase 3 complete.
Known Limitations
- Subsystem tools excluded from bridge: Subsystems call
mcp.AddTooldirectly, bypassingaddToolRecorded. Only the 10 built-in MCP tools appear in the REST bridge. Future: pass*ServicetoRegisterToolsinstead of*mcp.Server. - Flat schema only:
structSchemareflection handles flat structs but does not recurse into nested structs. Adequate for current tool inputs. - CLI spec produces empty bridge:
core api speccurrently generates a spec with only/health. Full MCP integration requires wiring the MCP service into the CLI command.
Phase 2 — Gin Plugin Roadmap (Complete)
All plugins drop in as With*() options on the Engine. No architecture changes needed.
Security & Auth
| Plugin | Option | Purpose | Priority |
|---|---|---|---|
WithAuthentik() |
|||
WithSecure() |
|||
WithSessions() |
|||
WithAuthz() |
|||
WithHTTPSign() |
Performance & Reliability
| Plugin | Option | Purpose | Priority |
|---|---|---|---|
WithCache() |
|||
WithTimeout() |
|||
WithGzip() |
|||
WithBrotli() |
andybalholm/brotli. Custom middleware (gin-contrib stub empty). |
Observability
| Plugin | Option | Purpose | Priority |
|---|---|---|---|
WithSlog() |
|||
WithPprof() |
|||
WithExpvar() |
|||
WithTracing() |
Content & Streaming
| Plugin | Option | Purpose | Priority |
|---|---|---|---|
WithStatic() |
|||
WithSSE() |
|||
WithLocation() |
Query Layer
| Plugin | Option | Purpose | Priority |
|---|---|---|---|
WithGraphQL() |
/graphql + optional playground. Accepts gqlgen ExecutableSchema. |
The GraphQL schema can be generated from the same Go Input/Output structs that define the REST endpoints. gqlgen produces an http.Handler that mounts directly on Gin. Subsystems opt-in via:
// Subsystems that want GraphQL implement this alongside RouteGroup
type ResolverGroup interface {
// RegisterResolvers adds query/mutation resolvers to the GraphQL schema
RegisterResolvers(schema *graphql.Schema)
}
This means a subsystem like go-ml exposes:
- REST:
POST /v1/ml/generate(existing) - GraphQL:
mutation { mlGenerate(prompt: "...", backend: "mlx") { response, model } }(same handler) - MCP:
ml_generatetool (existing)
Four protocols, one set of handlers.
Ecosystem Integration
| Plugin | Option | Purpose | Priority |
|---|---|---|---|
WithI18n() |
golang.org/x/text/language. |
||
| gin-contrib/graceful | — | Already implemented in Engine.Serve(). Could swap to this for more robust lifecycle management if needed. | — |
| gin-contrib/requestid | — | Already implemented. Theirs uses UUID, ours uses hex. Could swap for standards compliance. | — |
Implementation Order
Wave 1 (gateway hardening): Authentik, secure, slog, timeout, gzip, static DONE (20 Feb 2026)
Wave 2 (performance + auth): cache, sessions, authz, brotli DONE (20 Feb 2026)
Wave 3 (network + streaming): httpsign, sse, location, i18n, gqlgen DONE (20 Feb 2026)
Wave 4 (observability): pprof, expvar, tracing DONE (21 Feb 2026)
Each wave adds With*() options + tests. No breaking changes — existing code continues to work without any new options enabled.
Authentik Integration
Authentik is the identity provider and edge auth proxy. It handles user registration, login, MFA, social auth, SAML, and OIDC — so go-api doesn't have to.
Two Integration Modes
1. Forward Auth (web traffic)
Traefik sits in front of go-api. For web routes, Traefik's forwardAuth middleware checks with Authentik before passing the request through. Authentik handles login flows, session cookies, and consent. go-api receives pre-authenticated requests with identity headers.
Browser → Traefik → Authentik (forward auth) → go-api
↓
Login page (if unauthenticated)
go-api reads trusted headers set by Authentik:
X-Authentik-Username: alice
X-Authentik-Groups: admins,developers
X-Authentik-Email: alice@example.com
X-Authentik-Uid: <uuid>
X-Authentik-Jwt: <signed token>
2. OIDC Token Validation (API traffic)
API clients (SDKs, CLI tools, network peers) authenticate directly with Authentik's OAuth2 token endpoint, then send the JWT to go-api. go-api validates the JWT using Authentik's OIDC discovery endpoint (.well-known/openid-configuration).
SDK client → Authentik (token endpoint) → receives JWT
SDK client → go-api (Authorization: Bearer <jwt>) → validates via OIDC
Implementation in go-api
engine := api.New(
api.WithAuthentik(api.AuthentikConfig{
Issuer: "https://auth.lthn.ai/application/o/core-api/",
ClientID: "core-api",
TrustedProxy: true, // Trust X-Authentik-* headers from Traefik
}),
)
WithAuthentik() adds middleware that:
- Checks for
X-Authentik-Jwtheader (forward auth mode) — validates signature, extracts claims - Falls back to
Authorization: Bearer <jwt>header (direct OIDC mode) — validates via JWKS - Populates
c.Set("user", AuthentikUser{...})in the Gin context for handlers to use - Skips /health, /swagger, and any public paths
// In any handler:
func (r *Routes) ListItems(c *gin.Context) {
user := api.GetUser(c) // Returns *AuthentikUser or nil
if user == nil {
c.JSON(401, api.Fail("unauthorised", "Authentication required"))
return
}
// user.Username, user.Groups, user.Email, user.UID available
}
Auth Layers
Authentik (identity) → WHO is this? (user, groups, email)
↓
go-api middleware → IS their token valid? (JWT verification)
↓
Casbin authz (optional) → CAN they do this? (role → endpoint policies)
↓
Handler → DOES this (business logic)
Phase 1 bearer auth continues to work alongside Authentik — useful for service-to-service tokens, CI/CD, and development. WithBearerAuth and WithAuthentik can coexist.
Authentik Deployment
Authentik runs as a Docker service alongside go-api, fronted by Traefik:
- auth.lthn.ai — Authentik UI + OIDC endpoints (production)
- auth.leth.in — Authentik for devnet/testnet
- Traefik routes
/outpost.goauthentik.io/to Authentik's embedded outpost for forward auth
Dependencies
| Package | Purpose |
|---|---|
github.com/coreos/go-oidc/v3 |
OIDC discovery + JWT validation |
golang.org/x/oauth2 |
OAuth2 token exchange (for server-side flows) |
Both are standard Go libraries with no heavy dependencies.
Non-Goals
- gRPC gateway
- Built-in user registration/login (Authentik handles this)
- API versioning beyond /v1/ prefix
Success Criteria
Phase 1 (Done)
core api servestarts a Gin server with registered subsystem routesWebSocket subscriptions work alongside RESTSwagger UI accessible at/swagger/All endpoints return consistent Response envelopeBearer token auth protects all routesFirst subsystem integration (go-ml/api/) proves the pattern
Phase 2 (Done)
Security headers, compression, and caching active in productionSession-based auth alongside bearer tokensHTTP signature verification for Lethean network peersStatic file serving for docs site and SDK downloadsGraphQL endpoint at/graphqlwith playground
Phase 3 (Done)
core api specemits valid OpenAPI 3.1 JSON via runtime SpecBuildercore api sdkgenerates SDKs for 11 languages via openapi-generator-cliMCP tools bridged to REST endpoints via ToolBridge + BridgeToAPIOpenAPI spec includes Response[T] envelope wrappingSpec export to file in JSON and YAML formats