Codohue is a hybrid (sparse + dense) collaborative-filtering recommendation service for behavioral personalization.
It ingests events and raw catalog content over HTTP and durable Redis Streams, persists them in PostgreSQL, recomputes sparse/dense vectors on a schedule, auto-embeds catalog content (the recommended core mode), and serves recommendations and candidate rankings through HTTP APIs backed by Qdrant.
Architecture details, data model, API surface, design decisions: see ARCHITECTURE.md. Contributing — commits, branches, pull requests: see Contributing. Instructions for AI coding agents (Codex, Claude Code): see AGENTS.md. Go SDK: see sdk/go/README.md.
- Ingest events via
POST /v1/namespaces/{ns}/eventsor Redis Streamcodohue:events - Sparse CF + dense (
item2vec/svd/byoe/disabled) blended at serve time - Auto-embedding of raw catalog content per namespace (
dense_source="catalog"— the core mode;item2vec/svd/byoeremain as options) - Time-decayed trending ZSET in Redis; 5-minute recommendation cache
- Multi-tenant: each namespace owns its config, Qdrant collections, Redis streams, and API key
- Operational SPA on port
2002(session-cookie auth)
| Binary | Port | Purpose |
|---|---|---|
cmd/api |
2001 | Data-plane HTTP API + Redis Streams ingest worker |
cmd/cron |
— | Batch daemon: sparse + dense + trending recompute |
cmd/admin |
2002 | Admin server: /api/admin/v1/* (operator session cookie or scoped service token) + embedded SPA |
cmd/embedder |
2003 | Catalog auto-embedding worker |
- Go
1.26.7(server). SDK modules (pkg/codohuetypes,sdk/go,sdk/go/redistream) target Go1.24.13. - Docker + Docker Compose 2.24+ for local infra
golangci-lint(formake lint,make fmt)air(formake dev)migrate(for host-sidemake migrate-*)- Node.js 22.12+ and
npm(forweb/admin; Docker and CI use Node 22)
cp .env.example .env
# Generate and protect the three secret files first; see deploy/operator-auth.md.
make up-dThis starts PostgreSQL, Redis, Qdrant, migrations, a trusted provisioning init service and the four app containers. Follow operator setup to generate the owner, proxy and application secret files once before starting.
Verify:
curl http://localhost:2001/healthz
open http://localhost:2002 # admin SPA login (individual owner account)Other compose layouts:
| File | Purpose |
|---|---|
| compose.yaml | Dev — builds from source, infra included, auto-migrate |
| compose.app.yaml | App-only — builds from source, host networking to external infra |
| compose.prod.yaml | Prod — prebuilt GHCR images; each infra service is optional: run it in-compose via COMPOSE_PROFILES (local-db, local-redis, local-qdrant) or point at an external one via CODOHUE_DATABASE_URL / CODOHUE_REDIS_URL / CODOHUE_QDRANT_HOST |
See Docker deployment for app-only setup, external database requirements, deployment verification, and upgrades. Local infrastructure and embedder ports bind to loopback. Containers use Compose project names; keep the project directory/name unchanged when upgrading to reuse existing volumes.
For fast Go iteration without rebuilding images:
make up-infra # postgres + redis + qdrant only
make migrate-up # apply migrations from host
make run # cmd/api (or run-cron / run-admin / run-embedder)
make dev # cmd/api with live reload (air)
make dev-all # api (air) + admin + web/admin Vite togetherCodohue loads .env automatically when present. Required: DATABASE_URL (or DATABASE_URL_FILE). Human accounts and scoped service tokens replace the shared admin key; see setup, secret files and migration.
| Variable | Default | Used by |
|---|---|---|
REDIS_URL |
redis://localhost:6379 |
all |
QDRANT_HOST / QDRANT_PORT |
localhost / 6334 |
all |
CODOHUE_API_PORT |
2001 |
cmd/api |
CODOHUE_ADMIN_PORT |
2002 |
cmd/admin |
CODOHUE_API_URL |
http://localhost:2001 |
cmd/admin (proxy /healthz, inject events) |
CODOHUE_BATCH_INTERVAL_MINUTES |
5 |
cmd/cron |
CODOHUE_LOG_FORMAT |
text |
all (text or json) |
CODOHUE_CATALOG_MAX_CONTENT_BYTES |
32768 |
cmd/api catalog ingest default (override per namespace via admin API) |
CODOHUE_EMBED_MAX_ATTEMPTS |
5 |
cmd/embedder retries before dead-letter |
CODOHUE_EMBEDDER_HEALTH_PORT |
2003 |
cmd/embedder |
CODOHUE_EMBEDDER_REPLICA_NAME |
hostname | cmd/embedder consumer name |
CODOHUE_EMBEDDER_POLL_INTERVAL |
30s |
cmd/embedder rescan cadence |
CODOHUE_OBSERVABILITY_TOKEN |
unset | cmd/api, cmd/admin, cmd/embedder — gates /metrics and API/embedder /healthz?details=true. Unset means those routes do not exist (404) |
CODOHUE_STREAM_RETENTION_ENABLED |
false |
cmd/api, cmd/embedder — exact consumer-progress stream trimming |
CODOHUE_STREAM_RETENTION_INTERVAL |
1m |
how often a retention pass runs |
/healthz is public and reports only an aggregate status — no component
names, no dependency errors. Anything more detailed needs the dedicated
observability credential, which is deliberately separate from any operator or
service credential: a monitoring agent should not be able to delete namespaces.
curl http://localhost:2001/healthz # public: {"status":"ok"}
curl -H "Authorization: Bearer $CODOHUE_OBSERVABILITY_TOKEN" \
"http://localhost:2001/healthz?details=true" # per-component detail
curl -H "Authorization: Bearer $CODOHUE_OBSERVABILITY_TOKEN" \
http://localhost:2001/metrics # Prometheus scrapeThe embedder exposes the same pair on CODOHUE_EMBEDDER_HEALTH_PORT.
The admin process exposes its own protected metrics at http://localhost:2002/metrics.
Every namespace carries a monotonic generation. Deleting and recreating a
name mints a new one, and generation 2+ qualifies the namespace's physical
artifacts (trending:ns:g2, ns_g2_objects_dense, catalog:embed:ns:g2), so
work published against a deleted incarnation can never become visible to the
new one. Generation 1 keeps the original unqualified names, so upgrading moves
nothing.
Event, catalog and embed stream payloads carry an additive
namespace_generation. Producers that predate it keep working during the
adoption window — see deploy/backend-audit-remediation.md.
Data-plane writes answer distinguishably, so a client knows whether to retry:
| Situation | Status | Code |
|---|---|---|
| Namespace absent or deleted | 404 | namespace_not_found |
| Namespace mid-delete, or a system reset in progress | 409 | namespace_not_active |
| Configuration or lifecycle store unreadable | 503 | namespace_config_unavailable |
object_created_at more than five minutes in the future |
400 | invalid_object_created_at |
The admin plane is the only place namespaces are configured. Login via the SPA, or with curl:
# 1. Create session cookie
curl -c cookies.txt -X POST http://localhost:2002/api/v1/auth/sessions \
-H "Content-Type: application/json" \
-H "X-Codohue-CSRF: 1" \
-d '{"username":"owner","password":"<your-owner-password>"}'
# 2. Upsert namespace
curl -b cookies.txt -X PUT http://localhost:2002/api/admin/v1/namespaces/demo \
-H "X-Codohue-CSRF: 1" \
-H "Content-Type: application/json" \
-d '{
"action_weights": {"VIEW": 1, "LIKE": 5, "SHARE": 10},
"lambda": 0.01, "gamma": 0.5,
"max_results": 20, "seen_items_days": 14,
"alpha": 0.7,
"dense_source": "byoe", "embedding_dim": 4, "dense_distance": "cosine",
"trending_window": 24, "trending_ttl": 600, "lambda_trending": 0.1
}'The response returns a plaintext namespace API key once — only the bcrypt hash is stored. Data-plane calls send it as Authorization: Bearer <key>; no credential bypasses that check by default. For repeatable Compose setup, supply a pre-generated provision_api_key or use admin access provision; matching retries preserve credentials/configuration and conflicts return 409.
# HTTP
curl -X POST http://localhost:2001/v1/namespaces/demo/events \
-H "Authorization: Bearer <namespace-key>" \
-H "Content-Type: application/json" \
-d '{"subject_id":"user-123","object_id":"item-456","action":"VIEW","occurred_at":"2026-04-19T10:00:00Z"}'
# Redis Streams (no URL → namespace lives in the payload)
redis-cli XADD codohue:events '*' payload \
'{"namespace":"demo","subject_id":"user-123","object_id":"item-456","action":"VIEW","occurred_at":"2026-04-19T10:00:00Z"}'Built-in actions: VIEW, LIKE, COMMENT, SHARE, SKIP. Custom actions are accepted when defined in namespace_configs.action_weights.
make test # unit + package tests across all go.work modules
make test-race # with -race
make test-pkg PKG=./internal/ingest/... # one package tree
make up-infra && make migrate-up && make test-e2eThe E2E suite (build tag e2e) launches the API binary on port 12001 and exercises HTTP contracts, Redis Streams ingest, cron recompute, hybrid recommendation, and catalog auto-embedding.
Makefile is the source of truth for the rest — build, Docker, coverage, lint and migration targets.
The admin console at web/admin/ (Vite + React 19 + Tailwind v4) is embedded into cmd/admin at build time via the embedui build tag.
make dev-admin # Vite dev server (standalone)
make build-admin-embed # production admin binary with SPAmake build-admin (no -embed) builds an admin binary that serves only the API.
Work on a branch off main, keep the change focused, and open a pull request.
Both use Conventional Commits vocabulary.
- Commit:
type(scope): summary— imperative mood, concise, lowercase except proper nouns, API names and versions. - Branch:
type/scope-summary— lowercase and hyphen-separated, e.g.feat/api-namespace-routes. - Types:
feat,fix,refactor,test,docs,ci,chore. - Scopes:
api,cron,admin,embedder,ingest,compute,recommend,nsconfig,auth,catalog,embedstrategy,idmap,qdrant,redis,postgres,metrics,e2e,docs,ci.
Pick one primary scope per commit and keep each commit to one logical change. Describe
repository behavior, not Spec Kit phase labels or task IDs such as T012. Older styles
such as scope: summary or numeric branch names such as 001-feature-name are not used
for new work.
make fmt && make lint
make test && make test-race
make up-infra && make migrate-up && make test-e2e # if you touched API behavior,
# migrations, Redis/Qdrant, or cronThen in the pull request description:
- describe the behavior change and the modules it affects,
- call out migration or configuration changes explicitly,
- include a sample request and response for API changes,
- note rollout steps if Redis, Qdrant, or cron behavior changes.
- Keep feature logic in its domain package. The default shape is
docs.go,types.go,repository.go,service.go,handler.go, plus matching tests. - Domain packages may import
core,infra, andconfig— not each other. - Every package needs a
docs.goholding the package doc comment and package declaration only. - Every
service.go,repository.go,job.go, andworker.goneeds a_test.go;types.goanddocs.godo not. - Write code comments, doc comments, and TODOs in English.
- Update ARCHITECTURE.md whenever an endpoint, migration, storage contract, process responsibility, or cross-domain flow changes.
pkg/codohuetypesis the public wire contract; a deliberate change there must update its golden snapshots and the REST API table in ARCHITECTURE.md.- Never commit secrets or plaintext namespace keys.
make down-vremoves containers and volumes — full local reset.- Catalog ingest (
POST /v1/namespaces/{ns}/catalog) only works when the namespace usesdense_source="catalog", configured throughPUT /api/admin/v1/namespaces/{ns}/catalog; in that mode,PUT /v1/namespaces/{ns}/objects/{id}/embeddingreturns409 Conflictbecause the catalog pipeline owns object vectors.