Help us improve
Share bugs, ideas, or general feedback.
From grafana-app-sdk
Guides authoring CUE kind definitions for grafana-app-sdk projects: adding kinds, file structure, per-version schemas, and codegen configuration.
npx claudepluginhub grafana/skills --plugin grafana-coreHow this skill is triggered — by the user, by Claude, or both
Slash command
/grafana-app-sdk:cue-kind-definitionThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Kinds are the schema definitions that drive the entire grafana-app-sdk code generation pipeline. Each kind describes a Kubernetes-style resource type: its name, versions, and per-version schema. All Go types, TypeScript types, API clients, CRD manifests, and the AppManifest are generated from these CUE files.
Explains grafana-app-sdk concepts: CLI usage, project structure, deployment modes (standalone operator, grafana/apps, frontend-only), and the workflow for initializing apps on the Grafana App Platform.
Advises on REST, GraphQL, gRPC API design. Produces OpenAPI specs, GraphQL schemas, proto definitions with versioning strategies and consistency validation. Use for new endpoints in Express, Fastify, NestJS, Hono.
Provides Kubernetes CRD design patterns: schema with kubebuilder markers, validating/mutating webhooks, idempotent reconcile loops, and minimal RBAC for operators.
Share bugs, ideas, or general feedback.
Kinds are the schema definitions that drive the entire grafana-app-sdk code generation pipeline. Each kind describes a Kubernetes-style resource type: its name, versions, and per-version schema. All Go types, TypeScript types, API clients, CRD manifests, and the AppManifest are generated from these CUE files.
Use the CLI to scaffold a kind before editing:
grafana-app-sdk project kind add <KindName> --overwrite
This creates a .cue file with scaffolding, field comments, and example values. Read the generated comments carefully — they explain every field's purpose.
Always use
--overwritewhen re-running to regenerate scaffolding without losing manual additions.
grafana-app-sdk project kind add creates files directly in kinds/ — the default layout is flat, all in package kinds:
kinds/
├── manifest.cue # App manifest + version list declarations
├── mykind.cue # Common (cross-version) kind metadata
└── mykind_v1alpha1.cue # v1alpha1 schema + codegen config
For multi-version kinds the additional version files sit alongside:
kinds/
├── manifest.cue
├── mykind.cue
├── mykind_v1alpha1.cue
└── mykind_v1.cue
For larger, more complex kind definitions users may choose to organise kinds into per-kind and per-version subdirectories, each with their own package. The default CLI output uses the flat layout above.
A complete kind definition has three layers:
// kinds/mykind.cue
package kinds
myKind: {
kind: "MyKind" // Required: the kind name (PascalCase)
// other cross-version fields (scope, pluralName, validation, mutation, conversion, etc.)
// See references/kind-layout.md for the full field reference
}
Each version joins the common metadata with its own schema via CUE's & operator:
// kinds/mykind_v1alpha1.cue
package kinds
myKindv1alpha1: myKind & {
// Version-specific schema
schema: {
// spec: desired state — set by users/clients, never by the operator
spec: {
title: string
description: string | *"" // optional with default
count: int & >=0
enabled: bool | *true
}
// status: observed state — written only by the operator/reconciler,
// never by users. Mirrors Kubernetes spec/status conventions.
status: {
lastObservedGeneration: int | *0
state: string | *""
message: string | *""
}
}
// Code generation config
codegen: {
ts: { enabled: true } // generate TypeScript types
go: { enabled: true } // generate Go types and client
}
}
Since all files share package kinds, version objects are referenced directly — no imports needed in the flat layout:
// kinds/manifest.cue
package kinds
App: {
appName: "my-app"
versions: {
"v1alpha1": {
schema: myKindv1alpha1
}
}
}
This distinction follows Kubernetes conventions exactly:
spec — desired state. Written by users and clients. The operator reads spec and works to make the world match it. Admission handlers validate and mutate spec. Never write to spec from a reconciler.
status — observed state. Written only by the operator/reconciler after it has done work. Users and clients should treat status as read-only. Admission handlers must not modify status.
Typical status fields:
status: {
// Generation of the spec that was last successfully reconciled.
// Set to metadata.generation after a successful reconcile loop.
lastObservedGeneration: int | *0
// Human-readable summary of current state
state: string | *"" // e.g. "Ready", "Provisioning", "Error"
message: string | *"" // detail, especially on error
// References to objects created by the reconciler.
// e.g. the name of a ConfigMap or Deployment the reconciler provisioned.
provisionedConfigMap: string | *""
provisionedServiceAccount: string | *""
}
Fields that belong in status, not spec:
lastObservedGeneration / observedGenerationconditions (Kubernetes-style condition arrays)"Ready", "Degraded", etc.)Fields that belong in spec, not status:
#CUE supports named type definitions using the # prefix inside a schema block. Each #Definition generates a named Go struct and TypeScript interface alongside the kind's Spec type.
schema: {
#Threshold: {
value: float & >=0
severity: "info" | "warning" | "critical"
message: string | *""
}
#ResourceRef: {
name: string & != ""
namespace: string | *"default"
}
spec: {
title: string & != ""
alertThreshold: #Threshold
thresholds: [...#Threshold] // list of a defined type
targetRef?: #ResourceRef // optional
}
}
# definitions are scoped to the schema block they are declared in.
Prefer # definitions when:
[...#MyType])Inline structs are fine when:
Maps ({[string]: string}) and lists of scalars ([...string]) are always fine inline.
CUE is a superset of JSON. Commonly used types and constraints:
// Basic types
myString: string
myInt: int
myFloat: float
myBool: bool
myBytes: bytes
// Optional with default
name: string | *"default-value"
// Constraints (using & to intersect)
port: int & >=1 & <=65535
label: string & =~"^[a-z][a-z0-9-]*$" // regex constraint
// Enums (disjunctions)
status: "pending" | "active" | "archived"
// Maps (always fine inline)
labels: {[string]: string}
attrs: {[string]: _}
// Lists of scalars (fine inline)
tags: [...string]
// Optional field
description?: string
Routes can be defined at two levels. Both require corresponding Go handlers registered in app.go.
MyKind: {
kind: "MyKind"
schema: { ... }
routes: {
"/actions/process": {
"POST": {
name: "processMyKind" // unique within version; must start with a k8s verb
request: {
body: {
reason: string
}
}
response: {
jobId: string
status: string
}
}
}
}
}
versions: {
"v1alpha1": {
routes: {
namespaced: {
"/summary": {
"GET": {
name: "getNamespacedSummary"
response: { count: int }
}
}
}
cluster: {
"/health": {
"GET": {
name: "getHealth"
response: { status: string }
}
}
}
}
}
}
After adding routes, run grafana-app-sdk generate — routes are included in the AppManifest and ValidateManifest will fail if a handler is missing.
When a kind has multiple versions, fields declared in the common metadata object must match across all versions. Schema fields (inside schema.spec) can differ per version, but:
kind field must be identical in every versionv1, v2)status for server-managed fields; never put mutable server state in specControl what gets generated per kind per version:
codegen: {
ts: { enabled: true | false } // TypeScript types
go: { enabled: true | false } // Go types + client
}
Disabling go for frontend-only apps avoids generating unused Go code. Disabling ts for backend-only resources reduces TypeScript bundle size. Both default to true when omitted.
Always run generate after any change to .cue files:
grafana-app-sdk generate
The generated files in pkg/generated/ must never be edited manually — they are overwritten on every generate run.