This file is a small set of durable conventions for AI assistants working on this repository. Keep it short — only rules whose violation we'd actually want to catch in review.
This repository is public-facing. Anonymize anything that names a
specific deployment in files that ship with the repo: docs/,
README.md, CHANGELOG.md, design notes, runbooks, PR descriptions,
and commit messages.
| Don't write | Do write |
|---|---|
| Concrete backend / lab node hostnames | "a backend host" / <host> / "the GPU node" |
| Live production cluster apex hostnames | "the cluster's apex hostname" / <cluster>.example.com |
Tenant container names (<tenant>-container with real tenants) |
"a tenant container" / <tenant>-container (generic) |
| Containarium-core service container names (in operator-specific context) | "the platform Postgres LXC" / "core service LXCs" |
| Live IPs (LAN, GCP, Tailscale) | "a private LAN IP" / <sentinel-ip> |
| Internal tenant workload names (docker container, app names) | "a tenant workload" / "a docker compose service" |
Why: concrete names are reconnaissance signals — they reveal what's running where, which workloads to target, which backend names to fingerprint. The cost of generic wording is one extra word; the cost of leaking is permanent — PR descriptions and merged commits stay indexed forever.
Where concrete names ARE fine:
- Private operator runbooks not in the repo.
- Internal Slack / email / private docs.
- Operator memory files outside the repo.
Before pushing any doc / runbook / PR description: grep the diff for concrete names; if you're not sure, anonymize.
When adding a new platform action (anything that mutates Containarium state — create, expose, route, deploy, etc.):
- Land it as a
containarium <verb>cobra subcommand underinternal/cmd/<verb>.gofirst. - The MCP tool in
internal/mcp/tools.gois a thin wrapper over the same underlying Go function used by the CLI handler. Don't have the MCP tool call an HTTP endpoint that the CLI doesn't.
Why: The CLI is the canonical interface. Humans, shell scripts, CI, and demo recordings all consume it; MCP is one specific consumer (AI agents). Building CLI-first means:
- Anything an agent can do, a human can do via shell — symmetric surface with no agent-only escape hatches.
- Demo recordings are reproducible
bashscripts, not "spin up an agent + JWT token" rituals. - Tests focus on the CLI handler / shared client function; MCP correctness follows for free.
- The OSS community gets value from the CLI even without running an agent.
Anti-pattern: an MCP tool that talks to an HTTP endpoint with no
matching containarium <verb> subcommand. If you spot one, file a
follow-up to add the CLI counterpart.
The API is defined in proto/containarium/v1/*.proto first. Everything
else — gRPC server stubs, the HTTP/REST shim, the OpenAPI swagger doc,
the typed client — is generated from those protos via make proto
(which runs buf generate).
When adding a new endpoint:
- Add the RPC + request/response messages in
.proto. - Annotate the RPC with
(google.api.http)for the REST verb+path mapping and(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation)for the swagger description. make prototo regeneratepkg/pb/, the.pb.gw.gogateway shim, andapi/swagger/containarium.swagger.json.- Implement the gRPC method in
internal/server/. - Wire the typed client method in
internal/client/{grpc.go, http.go}.
Why: one contract drives three consumers (gRPC clients, REST/HTTP clients via grpc-gateway, and the OpenAPI viewer) — they cannot drift because they all regenerate from the same source. The MCP server (which speaks REST through grpc-gateway) gets every new endpoint for free. The CLI adds a thin cobra subcommand that calls the generated client.
Anti-pattern: writing a hand-rolled net/http handler under
internal/gateway/ for a new customer-facing endpoint. A handful of
legacy or internal-only endpoints (e.g. /healthz,
/authorized-keys/sentinel) live in the gateway directly — those
predate the convention or are infrastructure plumbing not in the
product contract. For anything an external caller, the CLI, or the
MCP server should hit, go through proto.
A corollary of proto-first: when proto already gives us typed primitives, use them.
-
Protobuf enums over magic strings. If a field's value is "must be one of X, Y, Z," it's an enum. Define the enum in
.proto, regenerate, and let the Go code use typed constants. Example: anos_typefield that acceptsubuntu | rocky9 | rhel9becomes aOSTypeenum on the proto andpb.OSType_*constants in Go — not astringparameter with a comment listing the allowed values. -
Well-defined Go structs over
map[string]interface{}. Every wire payload deserves a named struct with explicit fields. The only legitimate uses ofmap[string]interface{}are at the type-erasing boundary — the MCP JSON-RPC tool-arguments shape, generic gRPCgoogle.protobuf.Anycodecs, configuration files with truly unknown schemas. Convenience to avoid writing a 5-line struct is not a legitimate use.
Why: dynamic typing pushes correctness checks to runtime, where
they show up as expected string, got float64 inside a test (best
case) or in a customer's log (worst case). Static typing pushes them
to the compiler, which catches them before code review. The cost is
the struct definition; the saving is the bug-hunt months later.
Anti-pattern signs:
- A string field with a comment listing the allowed values.
- A function that takes
map[string]interface{}and pulls fields out by name with type assertions. - Tests that assert on
result["foo"].(string)instead ofresult.Foo.
When you spot one on the way past, fix it. The cost is small; the debt compounds.
cmd/mcp-server/— platform MCP. Outside-the-box admin operations: create container, list containers, expose port, etc. Talks to the platform's REST/gRPC API.cmd/agent-box/— in-the-box MCP. Linux-native operations (shell, files) running inside a single Containarium box. Reached over stdio, typically wrapped by SSH on the client side.
Don't mix them. Tools that operate on a single box's filesystem belong
in agent-box; tools that operate across boxes belong in the platform
MCP.