enkly — DDD microservices
This is the monolithic enkly-ai codebase (a vLLM-backed agentic coding assistant with an HTMX chat UI, billing, and a RAG index) restructured into eight independently deployable, Domain-Driven-Design-oriented microservices. No product feature was removed; see ARCHITECTURE.md for the bounded-context map, the handful of behavioral trade-offs the split required, and what to review before a production rollout.
Inference runs on two GPU-backed vLLM
OpenAI-compatible servers — one serving the chat model (Qwen, with
tool calling and extended thinking enabled), one serving the embedding
model — rather than a single local Ollama daemon. See pkg/vllmkit
for the client, docker-compose.yml / k8s/11-vllm.yaml for how the
two servers are deployed, and CHANGES.md's most recent entry for the
full writeup of what the migration off Ollama changed and why.
Services
| Service | Port | Owns |
|---|---|---|
gateway |
8080 (HTTP) | The HTMX web UI; the only service with a public HTTP surface |
iam |
9001 (gRPC) | Accounts, sessions, email verification, password reset |
billing |
9002 (gRPC) | Plans, Stripe checkout/portal/webhooks, daily usage quota |
conversation |
9003 (gRPC) | Conversations, messages, tool-call audit log |
workspace |
9004 (gRPC) | The agent's sandboxed filesystem/bash tools; file upload/download |
knowledge |
9005 (gRPC) | pgvector-backed RAG index/search |
agent |
9006 (gRPC) | The LLM run loop; orchestrates billing/conversation/workspace/knowledge |
notification |
— (RabbitMQ only) | Sends email in reaction to iam's events |
Communication: gRPC for every synchronous, request/response call
between services (see api/proto/); RabbitMQ for asynchronous,
no-reply-needed side effects — today, exactly the email-sending that
iam triggers (see pkg/eventbus).
Running it
cp .env.example .env # fill in STRIPE_* / ENKLY_SMTP_* if you want those features
docker compose up --build
vllm-chat and vllm-embed download their own model weights from
Hugging Face on first start — no separate pull step — so the first
up can take several minutes before agent/knowledge actually come
online (they wait on both vLLM servers' /health check via
depends_on). docker compose logs -f vllm-chat to watch progress.
This requires a host with an NVIDIA GPU and the NVIDIA Container
Toolkit installed (docker info | grep -i nvidia to check) — vLLM's
production performance depends on real GPU acceleration; see
docker-compose.yml's vllm-chat/vllm-embed services for the
details (pinned image tag, --gpu-memory-utilization, /dev/shm
sizing).
Then open http://localhost:8080.
To index a codebase for the search_codebase tool:
docker compose run --rm -v "$(pwd)":/src indexer -path /src
Claude-Code-inspired agent features
Two additions to the agent service, beyond the tool-for-tool monolith port, modeled on Claude Code:
- Project memory (
ENKLY.md) — drop anENKLY.mdfile in the sandboxed workspace root (same mechanism aswrite_file/the file explorer) and the agent folds it into its system prompt on every turn, under a "Project memory" heading — conventions, gotchas, how to run things in this repo. No file means no change in behavior. Seeservices/agent/internal/infrastructure/agentrun/agent.go'sMemoryLoader. dispatch_subagent(a Task tool) — the model can hand off a broad, open-ended investigation ("find every place X happens", "map out how Y flows through this service") to a nested, read-only sub-agent that explores on its own and reports back in one message. The sub-agent's own tool calls never enter the main conversation's context — only its final report does — and it structurally cannot write a file, edit a file, or run a command (its tool list excludes them, and its executor refuses them even if asked). Seeservices/agent/internal/infrastructure/toolexec/subagent.goandtoolcatalog.SubagentDefs().- Adaptive, interleaved thinking — the agent already streamed Qwen's reasoning trace
back per round (
ChatEvent.Thinking); two refinements bring that closer to how Claude's interleaved thinking actually behaves. First, thinking effort now varies by round instead of being one static setting for the whole run: round 0 (pure planning, no tool results yet) asks the chat model for"high"effort, later rounds defer to whatever default you've configured, and a template-parse retry asks for"low"so recovery stays fast — seeagentrun.ThinkPolicy/DefaultThinkPolicy. Second, a round's reasoning trace is no longer resent to the model on every subsequent round for the rest of the turn: once a newer round's thinking exists, the older one is dropped from the resent history (only the most recent round's trace is ever "in flight," matching the Claude API's own behavior of only needing the thinking block immediately preceding the current tool use) — seeagentrun.compactThinkingHistory, called alongside the existingcompactToolHistoryon every round. A new optionalCallbacks.OnThinkingRound(round int, thinking string)also fires once per round with just that round's own trace, for a caller that wants to render reasoning as discrete per-step blocks rather than one running blob. The gateway now does exactly that:domain.Stream.AddThinkingRoundrecords each round,agent.proto's newTurnEvent.thinking_round(aThinkingRound{round, thinking}oneof case) carries it over gRPC, andStreamChatemits it as athinking_roundSSE event rendered as its own collapsible card (message.html'sthinking_round/thinking_round_oob, styled via.tool-card.is-thinking-roundinstyle.css) inserted above the reply in execution order — the samebeforebeginOOB trick tool-call cards already use. This sits alongside, not in place of, the existing single auto-collapsing.thinking-card: that one shows the round currently in flight streaming live; the per-round cards are the settled record of every round once done, positioned right before the tool call each one led to.
Dynamic workflows, effort control, and long-context recovery
A further round of additions, modeled on Claude's agentic-coding feature set and scoped
to what's honestly deliverable against a vLLM-served Qwen backend — see CHANGES.md
§7 for the full writeup (written against the Ollama backend these features originally
shipped on; see CHANGES.md's latest entry for what the later vLLM migration changed).
The short version:
dispatch_workflow— the large-scale sibling ofdispatch_subagent: give it a batch of steps (one per file/service/call site in a migration) and it runs them as parallel, read-only sub-agents, honoring anydepends_onordering, verifying each report against its own task, and retrying once on a weak one — automatic verification and plan adjustment, without a second LLM call per step. Gated by plan (pkg/entitlements) and bounded byENKLY_WORKFLOW_MAX_PARALLEL.- Effort control (
pkg/effort) — five levels, Low through Max, picked per turn via a leading/effort <level>line in the chat message (seepkg/turnoptsfor why a directive line rather than a new proto field, for now) orENKLY_DEFAULT_EFFORTfor the deployment default. Each level sizes max rounds, context budget, thinking-effort floor, anddispatch_workflow's own parallelism cap. - Adaptive thinking — escalates one round's thinking effort above its level's floor on
a concrete difficulty signal (the prior tool call errored, produced an unusually large
result, or the turn is running long), instead of holding every round at one fixed
setting. On by default (
ENKLY_ADAPTIVE_THINKING). - Compaction ledger —
compactToolHistorynow keeps a running, auto-maintained note of every tool result it's trimmed away, so a long turn that's compacted its 30th result still has a stable map of what happened earlier instead of silently losing the thread. - Live system entries —
/system <instruction>(andpermission:/budget:variants) fold additional guidance into the system prompt mid-conversation, always appended after the stable base prompt rather than edited in place. - Fast mode —
/fastruns the turn on a second, smaller/quantized model served by its own vLLM deployment (ENKLY_FAST_CHAT_MODEL+ENKLY_FAST_VLLM_CHAT_URL) when one's configured; otherwise it's a no-op, so it's always safe to request.
First build
No go.sum is committed. Every Dockerfile runs go mod tidy during
the build to resolve and lock the full dependency graph (including
what grpc/protobuf/etc. pull in transitively) — this needs normal
outbound network access the first time, same as any other Go project.
After the first successful docker compose up --build, consider
running go mod tidy once at the repo root and committing the
resulting go.sum, then switching each Dockerfile's RUN go mod tidy
back to RUN go mod download for faster, better-cached rebuilds (see
the comment in services/iam/Dockerfile).
Regenerating gRPC code
gen/ is committed so the repo is buildable without protoc installed,
but if you edit anything under api/proto/, regenerate it:
make proto # requires protoc, protoc-gen-go, protoc-gen-go-grpc on PATH
Repository layout
api/proto/<context>/v1/*.proto gRPC contracts — the API surface between services
gen/ generated *.pb.go / *_grpc.pb.go (do not hand-edit)
pkg/ shared libraries (eventbus, auth helpers, render, vLLM client, Postgres helper, ...)
services/<name>/
cmd/server/main.go entrypoint: wiring, not logic
internal/domain/ entities, repository interfaces — no framework imports
internal/application/ use cases — transplanted business logic, transport-agnostic
internal/infrastructure/ Postgres/Stripe/vLLM/filesystem adapters implementing domain interfaces
internal/interfaces/grpc/ the only files that import the generated gRPC package
migrations/0001_init.sql this service's own schema (database-per-service)
tools/indexer/ standalone CLI, a knowledge-service gRPC client
docker-compose.yml full local stack
See ARCHITECTURE.md for the design rationale.