Guidance for AI coding agents working in this repository. Tool-agnostic: any agent that reads
AGENTS.md, CLAUDE.md, GEMINI.md, or a similar convention should follow this file.
Nebari Frames is a registry and exchange for Frames: scoped, text-based artifacts carrying
organizational context (terminology, rules, style, goals, business process, ...) into AI
conversations. A Frame composes through inheritance, is governed by role-based access control, and
is consumed either over MCP by any MCP-capable client or as files via the frames CLI.
Single Go module (github.com/nebari-dev/nebari-frames) containing a backend server, a Cobra CLI, a
React SPA embedded into the server binary, a Helm chart, and an Astro/Starlight docs site.
make test # go test ./... -race -coverprofile=coverage.out
make lint # golangci-lint run ./...
make proto # buf lint + buf generate (regenerates gen/go and gen/ts)
make build # CGO_ENABLED=0 go build -o nebari-frames-server ./backend/cmd/server
make build-web # npm ci && vite build into web/dist, then make build
make dev # backend :8080 (dev mode, fixture-seeded) + Vite :5173 with HMR
make dev-auth # Keycloak in Docker :8081 + backend on :5173 serving the built SPA
make dev-clean # kill orphan dev servers, drop dev DB + WAL/SHM, tear down Keycloak
make image # docker build (linux/amd64)
make e2e # blackbox RPC suite against a deployed frames (needs FRAMES_E2E_BASE_URL + FRAMES_E2E_TOKEN)
# One Go test / package
go test ./backend/internal/frames -run TestResolve -race
# Web (from web/)
npm run lint && npm run typecheck && npm test # eslint, tsc --noEmit, vitest run
npx vitest run src/pages/CatalogPage.test.tsx # single test file
# Chart
helm lint chart/
helm template frames chart/ --set auth.devMode=true
# Docs CLI reference (generated from cli/cmd)
go run ./tools/docs-genCI (.github/workflows/ci.yml) gates on five jobs: proto (buf lint plus a stale-codegen check on
gen/), go (golangci-lint pinned to v2.12 plus race tests), web (lint, typecheck, vitest),
chart (helm lint, template renders, kubeconform pinned to v0.7.0), and e2e-sandbox (deploys the
built image onto a kind Nebari sandbox via ArgoCD and exercises real Keycloak auth through the
gateway). e2e-sandbox also provisions a dedicated Keycloak client and user, then runs the
build-tagged blackbox RPC suite in e2e/ (make e2e) against the deployed pod through the real
gateway; that suite skips locally whenever FRAMES_E2E_BASE_URL and FRAMES_E2E_TOKEN are unset, so
make test is unaffected. docs.yml also fails if the generated CLI reference is stale. Run the
local equivalents before pushing.
Generated code is checked in. After touching proto/frames/v1/*.proto, run make proto and
commit gen/. After touching cli/cmd/*, run go run ./tools/docs-gen and commit
docs/site/src/content/docs/reference/cli/.
Request path: client -> Connect RPC (or MCP) -> auth interceptor -> frames.Service -> rbac.Can -> store.Repository.
proto/frames/v1/is the API source of truth. One service,FrameService, generated togen/go/frames/v1(Connect Go handlers/clients) andgen/ts/frames/v1(consumed by the SPA via the@genalias). All three surfaces (web, CLI, MCP) speak the same RPCs.backend/internal/serverwires the mux: unauthenticated/healthz,/readyz,/auth/config,/config.json(runtime branding); the FrameService at its generated path behindauth.NewInterceptor; optionally the MCP component; and the embedded SPA at/. The FrameService is injected rather than constructed here, so the Connect and MCP endpoints share one instance and cannot be configured differently. Request bodies are capped atserver.MaxRequestBytes, because a body is read in full before RBAC is consulted.backend/internal/authresolves identity.FRAMES_DEV_MODE=trueshort-circuits everything with a fixeddev-useridentity and a permanently-ready/readyz. Otherwise a lazy OIDC validator performs discovery from inside the pod, and/readyzstays 503 until it succeeds (fail closed).backend/internal/orgsturns claims into anrbac.Caller. Precedence is fixed: a stored membership, then a pending invite matched by email, then the baseline role fromFRAMES_DEFAULT_ROLE(empty means deny, which is the fail-closed default). The baseline membership is persisted, so those users appear in the members list and are promotable - and it is written insert-only, because the "no membership" read that leads there is not atomic with the write and an update would overwrite a role another request had just established.backend/internal/rbacis the single authorization decision point.Canevaluates in order: cross-org deny, admin allow, then per-frame grants for the user or the org. Roles areviewer | publisher | admin, permissionsread | edit | delete. A missing read permission is surfaced as 404, never 403, so frame existence does not leak.backend/internal/framesowns the Frame content model:schema.go- theDoc/SlotsYAML types. Parsing usesKnownFields(true): the slot schema is fixed and unknown keys are an error.slots.go-SlotTableis the single source of truth for slot keys, markdown headings, and content shape (terms / list / prose). Add or rename a slot here only; the.frame.mdcodec and the MCP composer both read it.templates.go/builtins.go- Frame templates: an authoring affordance, not Frames and not a Frame Spec concept. A template carries prefill content plus a per-slot rule (required / recommended / optional, with a note). Built-ins are embedded YAML parsed and validated at package init, so a malformed starter panics at startup rather than failing at a user's first click.Checkreports required slots a document leaves empty;publishmerges those with the schema's own violations so an author sees everything at once. Checked on create only: nothing is recorded on the Frame, sorequiredis an authoring aid rather than ongoing governance. Seedocs/adr/0001-frame-templates-are-not-frames.md. A prefill is held to the same content rules a Frame is (contentErrors), but at the write boundary (validateTemplateInput), never inParsePrefill: that decoder is also the read path (rowToTemplateruns every stored row through it), so a rule enforced there would make an already-stored row unreadable rather than merely unpublishable. Starters arrive through two doors - the write RPCs andloadBuiltinsat package init. Built-ins carry rules and notes but no content at all:builtinFilehas noprefillkey, so there is nothing for a content rule to check there.TestBuiltinFileCarriesNoPrefillis a tripwire over that premise - add such a field and it fails, telling you to runcontentErrorsinloadBuiltinsand to replace the tripwire.framemd.go- the Frame Spec v0.2.frame.mdcodec (YAML frontmatter plus one##section per slot). Round-trip fidelity matters:examples/*.yamlandexamples/*.frame.mdare checked-in conformance fixtures asserted byexamples_test.go.resolver.go- inheritance merge overextends/excludes. Later parents win, the child's own slots win last, cycles produceCycleError, and an unreadable ancestor propagatesErrParentUnreadablerather than silently dropping content.service.go-publishis the single write path behind both front doors. It authorizes before parsing caller-supplied content, enforcesMaxContentByteson the stored document, refuses a version that does not advancelatest_version, and refuses a write whose declared base version is no longer current. The Connect RPC stores the author's exact bytes;PublishDocmarshals the document instead, so comments and formatting survive a CLI or web publish.
backend/internal/storedefinesRepositoryplus an in-memory implementation used by tests;store/sqliteis the real one, with goose migrations instore/sqlite/migrations. Emails are canonical at rest (store.CanonicalEmail) and unique per org case-insensitively, so an invite cannot be shadowed by a case variant. The in-memory fake enforces the same unique constraints; where it cannot, tests reach for real SQLite and say why. Publishes go throughCreateFrameVersion, which inserts the frame row, version, inheritance edges, and grants atomically.frame_templatesholds org-authored templates, with prefill and field rules as opaque blobs so a change toSlotTablenever touches the schema; built-ins are not rows. SQLite is single-writer, so the deployment is pinned to one replica.backend/internal/mcpis a thin protocol adapter overframes.Service, exposing frames as MCP resources undernebari-frame://<org>/<name>[@<version>]plus RFC 9728 metadata at/.well-known/oauth-protected-resource. It is also a write surface:create_frameandupdate_framego throughframes.Service.PublishDocFrom, the same RBAC-enforcing path the Connect API uses, so the adapter itself performs no permission or validation logic. Two rules matter when changing it.update_framemerges onto the frame's own document fromSourceDocand never onto the composed formget_framereturns by default - merging onto a resolved document would copy every parent's slots into the child and drop itsextendsedges. And the base version it asserts against comes from the caller (base_version, read viaget_frame source=true), never from a fresh server-side read, which would always match and make the check inert. Request bodies are capped atmcp.MaxRequestBytes; the cap must wrap the outermost handler, since the bearer middleware is only installed when auth is on. URI parsing rejects anything structurally off (extra path segments,./..) instead of misrouting it.web/is the SPA.web/embed.goembedsweb/distinto the Go binary and serves it with an index.html fallback and a CSP assembled from the OIDC issuer origin and branded image origins.web/dist/index.htmlis deliberately kept in git (everything else underdist/is ignored) so//go:embedstill compiles on a clean checkout. Auth guards live inweb/src/app/(RequireAuth,RequireMembership,RequireAdmin); pages inweb/src/pages/; RPC transport and domain helpers inweb/src/lib/.- Seeding a form from server data comes in two shapes. A form that may re-seed keys its
reseton an identity that changes when the data does (the authoring page's edit path keys onversion.digest). A form that seeds once and then belongs to the author - a template prefill, the template edit dialog - must pinrefetchOnMount: "always"on the query and take its data fromfreshData()(web/src/lib/query-freshness.ts), which is where the reasoning lives: React Query returns a cached row synchronously and refetches behind it, counts a failed fetch inisFetchedAfterMount, and never clears cached data on error. Where the latch lives depends on where the form's identity comes from: the template edit dialog is keyed and mounted per target, so a boolean is its identity, while the authoring page's template comes from?template=and can change while the page stays mounted, so it latches on the id it seeded from. - A form seeded from a query does not render editable before the seed lands. Both template
surfaces withhold it (a skeleton, or the dialog's), because
resetreplaces form state wholesale andrefetchOnMount: "always"guarantees the seed arrives a round trip after the first render - so anything typed in between would vanish. react-hooks/set-state-in-effectreports at most one violation per effect, so a secondsetStatein the same effect can look clean while being the same construct. Do not read the linter's silence as a constraint, and do not add asetTimeoutclaiming to satisfy a rule that never fired - an unusedeslint-disableis itself reported.- Two schemas cover the same slot keys on purpose.
frame-yaml.tsdecodes whatever is stored (lenient, because rows predate rules);contentSlotsSchemainauthoring-schema.tsis what a human may submit, and every form that edits slot content resolves against it. The backend splits the same two jobs the same way (ParsevscontentErrors). - Error text splits by direction. A failed read states a fixed sentence - the server's message
there is storage or wiring detail and retrying is all the reader can do. A failed write shows
ConnectError.rawMessage, because it names what the caller must change; when it carriesFieldViolations, it belongs on the input instead.
- Seeding a form from server data comes in two shapes. A form that may re-seed keys its
cli/is theframesbinary (Cobra plus Viper; config at~/.config/frames/config.yaml, env prefixFRAMES_, device-flow login).chart/deploys onto a Nebari cluster; the nebari-operator provisions routing, TLS, and the OIDC client from theNebariAppresource.
- Table-driven Go tests. Tests sit beside the code they cover, including in
web/(*.test.tsx). - Migrations are only ever applied to a fresh database by the normal suite, which hides anything that
breaks on existing rows.
store/sqlite/migrations/migrate_legacy_test.gobuilds an older schema with the data a migration has to repair and migrates forward; extend it when a migration touches existing rows. - Never bump
chart/Chart.yaml'sversion/appVersionby hand: the release job stamps them from the git tag (version= tag withoutv,appVersion= the literal tag). - The SPA ships inside the image, not the chart. A frontend change reaches a cluster only through a new image tag.
- A test that asserts only "this was rejected" usually proves nothing: an unrelated 401, or a parse
failure, satisfies it just as well. Assert the specific code or message, and pair a rejection with
a control that must succeed. Reflective guards in
backend/internal/mcp/resources_test.gowalkframes.SlotTableandframes.Doc, so adding a slot without wiring it through the MCP input fails rather than silently dropping data. - Comments in this repo explain why a constraint exists (pinned CI versions, fail-closed
readiness, the vite
@bufbuild/protobufaliases). Preserve that rationale when editing near it, and keep new comments in the same register. - Always stop a dev loop with a single Ctrl-C. Killing it leaves the SQLite lock held and the next
start fails with
disk I/O error/database is locked;make dev-cleanrecovers. - Design docs in
docs/design/, architecture decision records indocs/adr/, client connection guides indocs/connect/, manual QA scripts indocs/qa/.