This guide documents the coding conventions for the kagent project. It applies to both human contributors and AI agents. It exists so that reviews can focus on design and correctness rather than repeating the same style feedback.
The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as described in RFC 2119: MUST rules are enforced in review; SHOULD rules require justification to deviate from.
Examples are marked ✓ (do this) and ✗ (don't do this).
- General Principles (all languages)
- Go
- Python (TODO)
- TypeScript (TODO)
- Helm / YAML (TODO)
These apply to every language in the repository.
- A PR MUST change one thing. Don't mix feature work with unrelated cleanup, devcontainer changes, sample agents, or changes carried over from other branches.
- Large mechanical changes (renames, generated code) SHOULD be split from behavioral changes.
- Errors MUST be propagated or explicitly handled — never silently discarded. If the entire purpose of a code path is to record or persist something, a failure in that path is an error, not a log line.
- If an error genuinely cannot be propagated, it MUST at minimum be logged with enough context to debug it.
- Logic repeated in two or more places SHOULD be extracted into a shared helper. Three or more repetitions MUST be extracted.
- Before writing a helper, check whether one already exists in the codebase.
-
Do not introduce options patterns, wrapper types, interfaces, or abstraction layers for trivial cases. The complexity cost must be justified by real, present need — not hypothetical future need.
"Do we really need to introduce an
optionspattern for 2 strings?" -
Symmetrically: don't keep an abstraction that only has one implementation and no near-term prospect of a second, unless it's needed for testing.
- Before creating a new type or utility, check: the standard library, upstream libraries already in use, the Kubernetes API, and the rest of this codebase. Mirroring or reimplementing an existing type MUST NOT be done without a documented reason.
- Unused imports, arguments, variables, functions, files, and binary entry points MUST be deleted, not left in "for later". Dead code creates confusion about what is actually used. Git history preserves it if needed.
- Only export symbols that callers outside the package genuinely need. Ask: "Does anyone outside this package need this?" If the answer is "only tests", test from within the package instead.
-
API fields, endpoint paths, and type names MUST reflect what users understand, not internal implementation details.
✗ Exposing user-facing agents as
teamsbecause the runtime calls them that. ✗ Naming a reference fieldimagewhen it's semantically areference/uri.
- Annotation keys, label keys, condition reasons, and other repeated string literals MUST be defined as named constants — in the API types package when they are part of the API surface.
Go code lives in the go/ workspace (go/api, go/core, go/adk). Run
make -C go lint before submitting; the golangci-lint config in
go/.golangci.yaml enforces a number of the rules below mechanically.
- Use
internal/aggressively. Code goes ininternal/by default; only intentionally reusable packages go inpkg/. - Shared types (CRDs, DB models, HTTP API shapes, client) live in
go/api. - When a package accumulates unrelated types or behaviors, split it into
descriptive sub-packages (e.g.
translator/agent,translator/mcp). Don't let a package become a catch-all. - K8s imports MUST use the standard aliases (enforced by
importas):corev1,metav1,appsv1,apierrors,kerrors, etc.
-
When the shape of data is known at compile time, define a named struct.
map[string]anyMUST NOT be used for data whose schema you control; it is reserved for genuinely schema-less JSON.// ✗ cfg := map[string]any{"model": "gpt-4o", "temperature": 0.2} // ✓ type ModelParams struct { Model string `json:"model"` Temperature float64 `json:"temperature"` }
-
Errors MUST be wrapped with context using
%w. Message pattern:"failed to <verb> <noun> <identifier>: %w".// ✓ return fmt.Errorf("failed to get agent %s: %w", req.NamespacedName, err)
-
Sentinel errors are package-level
var ErrXxx = errors.New(...), matched witherrors.Is(). -
When callers need to distinguish an error category, define a custom error type with an
Unwrap() errormethod (seetranslator/agent.ValidationError,httpserver/errors.APIError). -
Use
hashicorp/go-multierrorto accumulate multiple sub-errors when a loop must continue past individual failures (e.g. reconciling multiple secrets). -
Deprecated APIs MUST NOT be used (
ioutil→os, etc.). Prefer standard library utilities (slices.Equal,bufio.Scannerfor streaming reads) over hand-rolled implementations. Note thesortpackage is banned bydepguard; useslices.
-
Keep interfaces small and define them where they are consumed, not where they are implemented.
-
Every concrete implementation MUST carry a compile-time assertion:
var _ sandboxbackend.Backend = (*AgentsBackend)(nil)
-
Accept interfaces, return structs (return the interface only when the concrete type is intentionally hidden, as with
NewKagentReconciler).
-
A method receiver is justified only when the function (a) uses struct state, or (b) implements an interface. Otherwise it MUST be a plain (possibly exported) package function.
"Methods are important when you want to either hide the implementation of an interface, or you need to use some long-lived item in a struct. In this case it's neither, so really it should just be a public utility function."
-
Dependencies MUST be injected at construction time via
NewXxx(deps...)parameters — never via setters or lazy initialization that forcesnilchecks later.// ✗ set later, check nil everywhere r := NewRegistrar() r.SetClientRegistry(reg) // ✓ inject up front r := NewRegistrar(reg)
-
Use a
XxxConfigvalue struct once a constructor exceeds ~5 parameters (seeadk/pkg/app.AppConfig). Do not add functional options for a handful of fields. -
Keep exactly one constructor per type with all parameters; don't accumulate
NewXxxWithYyyconvenience shims. -
Related HTTP handlers share dependencies via an embedded
*Basestruct (seehttpserver/handlers). -
Configuration for Go binaries SHOULD be CLI flags grouped into config structs — not an ever-growing set of individual env vars.
context.Contextis always the first parameter of any function that does I/O (K8s, DB, network) and MUST be threaded through the entire call chain. HTTP calls usehttp.NewRequestWithContext. Never drop acancelfunction.- Context keys use unexported struct types, never strings.
- Structured logging only (
logrvia controller-runtime); key-value pairs, neverfmt.Sprintfinside a log call, neverfmt.Printffor logging. - Controllers/HTTP:
ctrllog.FromContext(ctx).WithName("...").WithValues(...). ADK:logr.FromContextOrDiscard(ctx). - Verbose or per-item output MUST be debug level (
log.V(1)or higher), notInfo. If the information is already returned to the caller (e.g. an HTTP response), don't also log it at info level.
-
Receiver names: single letter, the first letter of the concrete type name, used consistently across all methods of that type (
kforkagentReconciler,aforAgentController,mforModelConfigHandler). -
Acronyms are uppercase:
URL,ID,HTTP,MCP,ADK,TLS,API. -
Getters are
GetXxx(). -
CRD enum constants use the
TypeName_Valueunderscore pattern:// ✓ (kagent convention for CRD enums) const ( AgentType_Declarative AgentType = "Declarative" AgentType_BYO AgentType = "BYO" )
-
Boolean fields SHOULD be named so the zero value is the default (
DisableSystemCAs, notUseSystemCAsdefaulting to true).
- Maps and slices are already reference types —
*map[K]Vis almost never correct. There are a vanishingly small number of cases where a pointer to a slice is justified. - Don't deep-copy unless you actually mutate the original; don't dereference a pointer just to take its address again.
- Optional CRD fields MUST be pointers (see CRD API design).
All new API surface goes in v1alpha2. v1alpha1 MUST NOT receive new
features.
-
Every field carries explicit markers:
// +optionalor// +required, plus// +kubebuilder:default=...,// +kubebuilder:validation:Enum=...where applicable. The customkubeapilinterenforces much of this. -
Optional fields MUST be pointer types with
omitempty. Removingomitemptymakes a field required — never do this accidentally. -
oneOfsemantics (exactly/at most one of several fields set) MUST be enforced with CELXValidationrules at the CRD level, not with webhooks and not left to controller runtime checks:// ✓ // +kubebuilder:validation:XValidation:message="only one of stdio or http may be set",rule="[has(self.stdio), has(self.http)].filter(x, x).size() <= 1"
-
Use standard K8s types before inventing new ones:
[]metav1.Conditionfor status (withmeta.SetStatusCondition),corev1.LocalObjectReference/TypedLocalObjectReferencefor references. Start with single-namespace (local) references; widen later only if needed. -
References MUST be structured fields, not opaque
"namespace/name"strings that callers parse. -
Keep the API small: don't add convenience/sugar fields to status that can be expressed as conditions.
-
Shared sub-specs use embedded structs with
json:",inline"(e.g.BaseModelConfigembedded in provider-specific configs). -
Go interfaces in API packages carry
// +kubebuilder:object:generate=false. -
When unsure how to model something (polymorphism, oneOf, selectors, conditions), the Gateway API is the reference for CRD design prior art.
- Controllers are thin:
Reconciledelegates to theKagentReconcilerinterface. Business logic lives in thereconcilerpackage. - The standard reconcile loop:
Getthe resource; onapierrors.IsNotFound, clean up side effects and return nil.- Reconcile desired state.
- Update status (only writing when conditions or observedGeneration actually changed, to avoid hot-looping).
- Return the error to trigger exponential-backoff requeue.
- Controllers MUST be eventually consistent: fire-and-report, never
poll or block inside a reconcile waiting for external state. Use
RequeueAfterfor time-based re-checks. - Errors during reconciliation MUST be returned (to requeue), not merely logged — a logged-and-dropped error means the controller never retries.
- Ownership rules:
- A controller MUST NOT modify (patch, annotate, update status of, delete) resources it does not own — including resources owned by other controllers (e.g. kmcp) and user-created resources such as secrets.
- Secondary resources the controller creates MUST carry
OwnerReferences(controllerutil.SetControllerReference) so they are garbage-collected with the parent. Use finalizers when cleanup involves anything beyond owned K8s objects; adding a finalizer returnsctrl.Result{Requeue: true}.
- All controllers set
NeedLeaderElection: new(true).
- The translator is a pure function of cluster state: given inputs, it
deterministically produces outputs. It MAY read K8s resources referenced
by the API objects it is translating (e.g. resolving a
ModelConfigorSecreta spec points to), but it MUST NOT write or mutate K8s resources, make other network calls, or have knowledge of backends/runtimes. Side effects and async logic belong in the reconciler (or a dedicated controller). - Every behavioral change to a translator SHOULD come with golden tests
(
UPDATE_GOLDEN=true go test ...to regenerate).
- Prefer real, indexed columns for fields used as query predicates. Querying into JSON columns MAY be used when pragmatic, but if a JSON field becomes a common filter or grows fragile query logic, promote it to a proper column.
- The core long-running routines are owned by controller-runtime, the HTTP
server, and the A2A framework. Long-running background work implements
manager.Runnablerather than being spawned ad hoc. - Goroutines in business logic MAY be used when there's a real need
(fan-out, timeouts, parallel I/O), but they MUST be bounded, tied to a
context.Contextfor cancellation, and must not leak.
- Framework: stdlib
testing+testify(requirefor fatal assertions,assertfor non-fatal). No Ginkgo. Test files use external test packages (package foo_test) unless testing unexported internals. - Tests are table-driven with
t.Run, usingname/input/want/wantErrfields. - Helpers call
t.Helper()first and register cleanup witht.Cleanup. - Mocks are small hand-written structs — no generated mocks. K8s is faked with
fake.NewClientBuilder(); Postgres uses testcontainers (skipped undertesting.Short()). - Integration/E2E tests MUST exercise the system through its own APIs, the way a real client would — not by pre-seeding or asserting via raw SQL, which sidesteps the code being validated.
- Assertions must be meaningful: assert on values that prove behavior, not strings echoed from the request.
TODO — to be drafted. Known review themes to incorporate:
- Tools should be general-purpose, not hyper-specific sub-commands.
- Validate configuration at
__init__/config-load time (fail fast), not lazily. - No
except Exception: pass— never swallow exceptions silently; log at minimum. - Reuse upstream library types (e.g. ADK
BaseTool) before reimplementing. - Anything exported from a package should belong to that package's domain.
TODO — to be drafted. Known baseline: no any; UI code only (no backend logic).
TODO — to be drafted. Known review themes to incorporate:
- Create resources conditionally (e.g. only create a secret when the value is set).
- Prefer example values files over proliferating
make install-*targets. - Sub-charts don't need explicit
enabledflags. - Chart upgrades must be backwards-compatible.