From grafana-app-sdk
Guides authoring CUE kind definitions, schemas, versioning, validation, and codegen config for grafana-app-sdk app platform projects.
npx claudepluginhub grafana/skills --plugin grafana-app-sdkThis skill uses the workspace's default tool permissions.
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.
Guides grafana-app-sdk project initialization, CLI usage, deployment modes (standalone operator, grafana/apps, frontend-only), and Grafana App Platform workflows.
Provides Kubernetes CRD design patterns: schema with kubebuilder markers, validating/mutating webhooks, idempotent reconcile loops, and minimal RBAC for operators.
Scaffolds new Go projects for Grafana resources-as-code using gcx dev scaffold. Generates repo structure with CI/CD, main.go, and dashboard examples. Triggers on 'new project', 'scaffold', 'get started with gcx'.
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.