Skip to content

Build a Custom Discovery CodeCollection

Two ways to read this guide. Skim the short story below for the 60-second summary, or jump into the long story for the full ~20-minute walkthrough with copy-pasteable YAML and screenshots.

Short story (TL;DR)

  • You want SLXs for a resource type that isn’t in any published CodeCollection — e.g. a Kubernetes CRD, a workload with a specific label, or a cloud resource with a custom tag.
  • Ship a private CodeCollection with a single Skill Template that contains no Robot code — only a generation rule and three Jinja templates (SLX, SLI, TaskSet).
  • Every rendered SLX points its codeBundle.repoUrl at a generic Skill Template in the RunWhen Generic CodeCollection — usually k8s-kubectl-cmd. That shared collection supplies sli.robot / runbook.robot; your templates supply the concrete KUBECTL_COMMAND (plus its jq post-processing).
  • Wire the private repo into your RunWhen Local runner alongside the shared collection. The workspace-builder clones both, lists CRDs in your cluster, and renders one SLX per matched resource — no runner image rebuild required.
  • Worked example (below): monitor Crossplane GCP Bucket CRDs with a fractional-conditions SLI and a TaskSet that files issues on Ready=False or Synced=False.

Ready for the details? Continue to the full walkthrough →

Long story (full walkthrough)

A CodeCollection is a Git repository that groups related Skill Templates — the same concept surfaced as Skill Templates on registry.runwhen.com and as CodeBundles in the repo layout. Each Skill Template normally ships two things: discovery rules (which tell the workspace-builder what to look for in your infrastructure) and runtime code (Robot Framework .robot files that actually run checks and produce SLI values or issues).

This guide walks through the Custom Discovery CodeCollection pattern: a private CodeCollection whose Skill Template extends what RunWhen can see — Kubernetes CRDs, labeled workloads, cloud tags that aren’t covered by any published CodeCollection — by shipping only rules and templates and delegating the runtime to an already-published generic Skill Template in the RunWhen Generic CodeCollection (repo: rw-generic-codecollection). It’s the fastest way to teach RunWhen about organization-specific resources without maintaining any Python or Robot code of your own.

We’ll use the Crossplane GCP Bucket as the concrete example, but the same steps apply to any Kubernetes CRD.

What you’ll build

By the end of this guide you’ll have a private CodeCollection that:

  1. Lives in your own Git host (GitHub, GitLab, Gitea, Bitbucket — anywhere your RunWhen Local runner can clone from).
  2. Contains no Robot code of its own. It ships only:
    • Generation rules — YAML under .runwhen/generation-rules/ that tells the workspace-builder which resources to enumerate and how many SLXs to render for each match.
    • Templates — Jinja files under .runwhen/templates/ that render the ServiceLevelX, ServiceLevelIndicator, and Runbook resources for each matched Kubernetes object.
  3. Reuses an existing generic Skill Templatek8s-kubectl-cmd from the RunWhen Generic CodeCollection — as the runtime for every generated SLI and TaskSet. Your templates supply the concrete kubectl command; the generic bundle handles execution, secrets, and output shaping.
  4. Discovers a concrete example resource type — Crossplane GCP Bucket custom resources (buckets.storage.gcp.upbound.io) — and produces one SLX per Bucket with a fractional health SLI and a condition-based TaskSet that files issues when a bucket is degraded.

The pattern in one diagram

The workspace-builder loads your rules, enumerates the CRDs in the cluster, and renders one SLX per matched resource. Every rendered SLX points its codeBundle.repoUrl at rw-generic-codecollection, so the runner never needs to know about — or clone — your private repo again at execution time.

When to use this pattern

Choose the Custom Discovery CodeCollection pattern when:

  • You want to discover organization-specific resources (CRDs, tagged workloads, labeled namespaces) without forking a shared collection.
  • The runtime you need already exists as a Skill Template in the RunWhen Generic CodeCollection. Common candidates:
  • You want your discovery rules to update without a runner image rebuild. The workspace-builder re-clones your repo on each reconcile, so changes land quickly.

Don’t use it when you need custom Robot keywords or a runtime that isn’t already generic. For complex Bash or Python logic that just doesn’t fit a single CLI + jq invocation, see the tool-builder alternative below — it stays inside the Custom Discovery CodeCollection pattern. Only reach for a traditional CodeCollection with .robot files when even a full script isn’t enough — see Tasks & CodeBundles.

Prerequisites

You’ll need:

  • A running RunWhen Local runner (SaaS with a private runner, or a self-hosted install). If you don’t have one yet, follow the Quick Start.
  • Cluster access for the workspace-builder — the service account must have get / list / watch on the CRD you want to discover. See Kubeconfigs & Service Accounts for the RBAC pattern.
  • A Git host you can commit to — public or private, as long as the workspace-builder pod can reach it. For a private repo you’ll also need an access token with read scope.

Vocabulary primer

TermMeaning
CodeCollectionA Git repository that RunWhen clones. Contains one or more CodeBundles / Skill Templates.
CodeBundle / Skill TemplateA subdirectory of a CodeCollection (codebundles/<name>/) — one scripted automation package. “CodeBundle” is the on-disk name; “Skill Template” is how the same thing is labelled on registry.runwhen.com. Traditionally ships Robot code plus discovery rules; in this pattern ours ships only rules and templates.
Generation ruleYAML under .runwhen/generation-rules/. Declares which resource types to scan and how to build SLXs from each match.
TemplateJinja YAML under .runwhen/templates/. Rendered per matching resource by the workspace-builder into the final SLX / SLI / Runbook artifacts.
SLXService Level Expectation — the top-level unit of monitoring in RunWhen. Bundles an SLI, an optional SLO, and one or more Runbooks.
SLIService Level Indicator — a numeric health signal (here 0.0 to 1.0) pushed on an interval.
Runbook / TaskSetThe interactive troubleshooting sequence — called a “Runbook” in kind, “TaskSet” in the UI.
workspace-builderThe pod (image runwhen-local) that clones CodeCollections, indexes resources, matches rules, renders templates, and uploads SLXs to the platform.

Repository layout

The whole CodeCollection is just this:

your-private-codecollection/
├── README.md
└── codebundles/
└── gcp-bucket-crossplane-health/
├── README.md
└── .runwhen/
├── generation-rules/
│ └── gcp-bucket-crossplane-health.yaml
└── templates/
├── gcp-bucket-crossplane-health-slx.yaml
├── gcp-bucket-crossplane-health-sli.yaml
└── gcp-bucket-crossplane-health-taskset.yaml

No meta.yaml, no runbook.robot, no sli.robot. The templates reference Robot files, but they point at a different repository — see Step 3 below.

Here’s the same layout as it appears in the private Gitea repo used for this walkthrough — one YAML per file, no hidden .robot or Python code alongside them:

Gitea file browser showing the codebundles/gcp-bucket-crossplane-health/.runwhen/ directory with two subfolders — generation-rules/ and templates/ — and the top-level README.md, matching the layout described in the tree above

Step 1 — Inspect the target CRD

Before writing a single line of YAML, use kubectl to confirm what the API server actually calls the resource. Small typos here (singular vs plural, wrong group) are the single most common reason discovery silently returns zero matches.

Terminal window
kubectl api-resources | grep storage.gcp.upbound.io

You’ll get output roughly like this:

NAME SHORTNAMES APIVERSION NAMESPACED KIND
buckets storage.gcp.upbound.io/v1beta2 false Bucket
bucketiammembers storage.gcp.upbound.io/v1beta1 false BucketIAMMember
hmackeys storage.gcp.upbound.io/v1beta1 false HMACKey

Three columns matter:

  1. NAME (buckets) — the plural resource name. This is what you put in the generation rule, not the singular Bucket and not the KIND column.
  2. APIVERSION (storage.gcp.upbound.io/v1beta2) — the API group and version. The part before the / is the group; the part after is the version.
  3. NAMESPACED (false) — whether the resource is scoped to a namespace. Crossplane managed resources are all cluster-scoped, which affects the qualifiers you pick below.

Confirm you actually have some instances to discover:

Terminal window
kubectl get buckets.storage.gcp.upbound.io -o wide
NAME READY SYNCED EXTERNAL-NAME
runwhen-nonprod-shared-litellm-logging True True runwhen-nonprod-shared-litellm-logging
runwhen-nonprod-shared-loki True True runwhen-nonprod-shared-loki
runwhen-nonprod-shared-mimir True True runwhen-nonprod-shared-mimir
runwhen-nonprod-shared-tempo True True runwhen-nonprod-shared-tempo

Then look at the health signal you’ll turn into an SLI. For Crossplane, .status.conditions[] is the natural choice — every managed resource carries Ready and Synced:

Terminal window
kubectl get buckets.storage.gcp.upbound.io <name> -o json | jq .status.conditions
[
{ "lastTransitionTime": "2025-11-14T10:15:11Z", "reason": "Available", "status": "True", "type": "Ready" },
{ "lastTransitionTime": "2025-11-14T10:15:10Z", "reason": "ReconcileSuccess", "status": "True", "type": "Synced" }
]
  • Ready — the external resource (the actual GCS bucket in Google Cloud) exists and is available. Ready=False means something in GCP is wrong: missing IAM, quota, deleted out-of-band, etc.
  • Synced — the last reconcile between the Crossplane spec and the provider succeeded. Synced=False means Crossplane couldn’t talk to the provider or hit a validation error.

We’ll compute the SLI as (#True conditions) / (#total conditions) in Step 3. A two-condition resource with one failing condition returns 0.5.

Step 2 — Write the generation rule

Create the file at codebundles/gcp-bucket-crossplane-health/.runwhen/generation-rules/gcp-bucket-crossplane-health.yaml:

apiVersion: runwhen.com/v1
kind: GenerationRules
spec:
platform: kubernetes
generationRules:
- resourceTypes:
- buckets.storage.gcp.upbound.io
matchRules:
- type: pattern
pattern: ".+"
properties: [name]
mode: substring
slxs:
- baseName: xp-bkt-hlth
qualifiers: ["resource", "cluster"]
baseTemplateName: gcp-bucket-crossplane-health
levelOfDetail: detailed
outputItems:
- type: slx
- type: sli
- type: runbook
templateName: gcp-bucket-crossplane-health-taskset.yaml

The same file viewed in Gitea — this is what the workspace-builder actually clones on every reconcile:

Gitea file view of gcp-bucket-crossplane-health.yaml on the main branch, showing the GenerationRules manifest with syntax highlighting, line numbers, the resourceTypes list containing buckets.storage.gcp.upbound.io, the substring pattern matchRule, and the slxs block declaring an SLX/SLI/Runbook output for every matched bucket

Walk through it block-by-block:

Envelope. kind must be GenerationRules (plural). spec.platform sets the default platform for every rule in the file. spec.generationRules is a list — one file can define many rules.

resourceTypes uses the syntax plural.group[/version]. Copying buckets and storage.gcp.upbound.io straight from kubectl api-resources and omitting the version means the indexer will use the API server’s preferred version — which is usually what you want because it survives CRD version bumps. Pin /v1beta2 (or whatever) only when you rely on a specific schema shape.

This string is also the selective discovery signal: the Kubernetes indexer only lists CRDs that appear in a loaded generation rule. Declaring buckets.storage.gcp.upbound.io here is what causes it to be enumerated at all. There’s no separate customResourceTypes: list to maintain elsewhere in your workspace config.

matchRules are ANDed together. The pattern predicate here says “the resource’s name property must contain a substring matching the regex .+ — true for every named resource, so every Bucket matches. Tighten it as needed:

# Only buckets whose name starts with prod-
- type: pattern
pattern: "^prod-"
properties: [name]
mode: substring
# Only buckets that carry a specific label
- type: exists
path: "resource/metadata/labels/env"
# Combine predicates with and/or
- type: and
matches:
- type: pattern
pattern: "^runwhen-prod-.*"
properties: [name]
mode: substring
- type: exists
path: "resource/status/conditions"

slxs controls the output. baseName (short — keep it under 15 chars) is appended to the qualifier prefix to form the final slx_name. qualifiers controls cardinality: ["resource", "cluster"] means “one SLX per matching resource, prefixed with the cluster name so it stays stable across multi-cluster workspaces”. Do not include namespace for Crossplane managed resources — they’re cluster-scoped and have no namespace.

baseTemplateName is the file-name stem for the templates. Each outputItem resolves to <baseTemplateName>-<type>.yaml unless overridden with templateName. We explicitly override type: runbook to point at -taskset.yaml because that’s the convention used across most public RunWhen bundles — the file is a Runbook kind, but the naming reflects the “TaskSet” label used in the UI.

For a Bucket named runwhen-nonprod-shared-litellm-logging on cluster shared-cluster, the rule produces an SLX named roughly:

shared-cluster-runwhen-nonprod-shared-litellm-logging-xp-bkt-hlth

Long names get truncated with a hash suffix; the exact rendering happens in the workspace-builder.

Common rule pitfalls

  • Wrong plural. The single biggest cause of “nothing gets discovered”. Always copy the plural directly from kubectl api-resources. Never guess it from the Kind: Bucketbuckets is intuitive, but Endpointsendpoints (already plural) and NetworkPolicynetworkpolicies are not.
  • Version pinning against a moving CRD. If a provider bumps a CRD version and drops the old one, your rule silently returns nothing. Omit the version unless you have a schema reason to pin it.
  • Missing RBAC. The workspace-builder needs get / list / watch on the CRD. If the CRD was installed after the runner’s RBAC was applied, you may need to add a new ClusterRole / ClusterRoleBinding explicitly.
  • Cluster vs namespace scope. For namespaced CRDs, include the CRD’s namespaces in your cloudConfig.kubernetes.namespaces list (or leave that list empty to scan all namespaces). For cluster-scoped CRDs, this is irrelevant.

Step 3 — Write the templates

Three files, one per output item. The most important thing about all three is what they don’t contain: any pointer to Robot code in this repo. The SLI and TaskSet templates delegate to k8s-kubectl-cmd in the RunWhen Generic CodeCollection — that’s the “delegation trick” that makes this pattern rules-only.

The SLX template

Create codebundles/gcp-bucket-crossplane-health/.runwhen/templates/gcp-bucket-crossplane-health-slx.yaml:

apiVersion: runwhen.com/v1
kind: ServiceLevelX
metadata:
name: {{slx_name}}
labels:
{% include "common-labels.yaml" %}
annotations:
{% include "common-annotations.yaml" %}
spec:
imageURL: https://storage.googleapis.com/runwhen-nonprod-shared-images/icons/kubernetes.svg
alias: Crossplane GCP Bucket {{match_resource.resource.metadata.name}} Health
asMeasuredBy: Fraction of Crossplane .status.conditions[] that are True (Ready + Synced).
configProvided:
- name: OBJECT_NAME
value: {{match_resource.resource.metadata.name}}
- name: API_GROUP
value: storage.gcp.upbound.io
- name: KIND
value: Bucket
owners:
- {{workspace.owner_email}}
statement: >-
Crossplane GCP Bucket {{match_resource.resource.metadata.name}} should have
every status condition (Ready, Synced) in state True 99.5% of the time.
additionalContext:
{% include "kubernetes-hierarchy.yaml" ignore missing %}
qualified_name: "{{ match_resource.qualified_name }}"
tags:
{% include "kubernetes-tags.yaml" ignore missing %}
- name: access
value: read-only
- name: managed-by
value: crossplane
- name: provider
value: upbound-gcp

A few notes:

  • configProvided on the SLX itself is metadata, not runtime input. It’s shown in the UI under the SLX’s “configured properties” panel. Use it for values that describe the thing being monitored, not values passed to the robot doing the monitoring — that’s what the SLI and Runbook configProvided blocks are for.
  • kubernetes-hierarchy.yaml and kubernetes-tags.yaml are shared includes shipped by the workspace-builder. They emit the hierarchy: list (used for the UI breadcrumb: platform → cluster → resource) and a standard set of tags (platform, cluster, resource_type, resource_name, plus every Kubernetes label as [k8s]<key>).
  • workspace.owner_email is populated automatically from workspaceInfo.workspaceOwnerEmail.

The SLI template

Create .runwhen/templates/gcp-bucket-crossplane-health-sli.yaml:

apiVersion: runwhen.com/v1
kind: ServiceLevelIndicator
metadata:
name: {{slx_name}}
labels:
{% include "common-labels.yaml" %}
annotations:
{% include "common-annotations.yaml" %}
spec:
displayUnitsLong: Percentage
displayUnitsShort: '%'
locations:
- {{default_location}}
description: >-
Fraction of Crossplane .status.conditions[] that are True for
GCP Bucket {{match_resource.resource.metadata.name}}. Healthy = 1.0.
codeBundle:
repoUrl: https://github.com/runwhen-contrib/rw-generic-codecollection.git
ref: main
pathToRobot: codebundles/k8s-kubectl-cmd/sli.robot
intervalStrategy: intermezzo
intervalSeconds: 300
configProvided:
- name: TASK_TITLE
value: 'Crossplane GCP Bucket {{match_resource.resource.metadata.name}} condition health'
- name: KUBECTL_COMMAND
value: |
kubectl get buckets.storage.gcp.upbound.io {{match_resource.resource.metadata.name}} -o json | jq -r '(.status.conditions // []) as $c | if ($c|length)==0 then 0 else ([$c[]|select(.status=="True")]|length)/($c|length) end'
- name: TIMEOUT_SECONDS
value: '120'
secretsProvided:
{% include "kubernetes-auth.yaml" ignore missing %}
alertConfig:
tasks:
persona: eager-edgar
sessionTTL: 10m

This is where the delegation happens. The codeBundle block points at the RunWhen Generic CodeCollection, not at your private CC. The runner will clone that repo, load k8s-kubectl-cmd’s sli.robot, and run whatever KUBECTL_COMMAND you passed in.

The jq expression computes the fraction of True conditions:

FragmentMeaning
(.status.conditions // []) as $cBind the conditions array (or empty if missing) to $c
if ($c|length)==0 then 0No conditions yet → return 0 (unhealthy). Signals “the resource has not reconciled”.
else ([$c[]|select(.status=="True")]|length) / ($c|length)Otherwise: fraction of True conditions over total

The generic robot picks up whatever gets printed to stdout, parses it as a float, and pushes it as the metric.

About the include. kubernetes-auth.yaml is a workspace-builder include that resolves to the kubeconfig secret configured on your runner (via the custom.kubeconfig_secret_name in workspaceInfo.yaml). You don’t need to hardcode the secret name here.

The TaskSet template

Create .runwhen/templates/gcp-bucket-crossplane-health-taskset.yaml:

apiVersion: runwhen.com/v1
kind: Runbook
metadata:
name: {{slx_name}}
labels:
{% include "common-labels.yaml" %}
annotations:
{% include "common-annotations.yaml" %}
spec:
location: {{default_location}}
codeBundle:
repoUrl: https://github.com/runwhen-contrib/rw-generic-codecollection.git
ref: main
pathToRobot: codebundles/k8s-kubectl-cmd/runbook.robot
configProvided:
- name: TASK_TITLE
value: 'Inspect Crossplane GCP Bucket {{match_resource.resource.metadata.name}} conditions'
- name: TIMEOUT_SECONDS
value: '300'
- name: ISSUE_JSON_QUERY_ENABLED
value: 'true'
- name: ISSUE_JSON_TRIGGER_KEY
value: issuesIdentified
- name: ISSUE_JSON_TRIGGER_VALUE
value: 'true'
- name: ISSUE_JSON_ISSUES_KEY
value: issues
- name: KUBECTL_COMMAND
value: |
kubectl get buckets.storage.gcp.upbound.io {{match_resource.resource.metadata.name}} -o json | jq -c '(.status.conditions // []) as $c | { issuesIdentified: (($c|length)==0 or ([$c[]|select(.status!="True")]|length > 0)), issues: ( if ($c|length)==0 then [ { title: "Crossplane Bucket {{match_resource.resource.metadata.name}} has no status conditions yet", severity: 3, expected: "Bucket should have Ready and Synced conditions populated by the provider", actual: "status.conditions is empty or missing", reproduce_hint: "kubectl get buckets.storage.gcp.upbound.io {{match_resource.resource.metadata.name}} -o yaml", next_steps: "1. Check the crossplane-system provider pod is Running. 2. kubectl describe the bucket to see reconciliation events. 3. Verify the ProviderConfig exists and is Healthy.", details: (.status | tostring) } ] else [ $c[] | select(.status!="True") | { title: ("Crossplane Bucket " + .type + " condition is not True: {{match_resource.resource.metadata.name}}"), severity: (if .type=="Ready" then 2 else 3 end), expected: (.type + " condition should be True"), actual: (.type + "=" + .status + " reason=" + (.reason // "n/a") + " message=" + (.message // "n/a")), reproduce_hint: "kubectl get buckets.storage.gcp.upbound.io {{match_resource.resource.metadata.name}} -o yaml", next_steps: "1. kubectl describe the bucket. 2. Inspect provider pod logs for reconcile errors. 3. Verify ProviderConfig credentials. 4. Check GCP IAM permissions.", details: (. | tostring) } ] end ) }'
secretsProvided:
{% include "kubernetes-auth.yaml" ignore missing %}

Same shape as the SLI: delegate to k8s-kubectl-cmd/runbook.robot, pass a KUBECTL_COMMAND. The key differences are the four ISSUE_JSON_* variables, which enable JSON-issue mode in the generic runbook:

  • ISSUE_JSON_QUERY_ENABLED=true tells the runbook to parse stdout as JSON.
  • ISSUE_JSON_TRIGGER_KEY=issuesIdentified names the boolean field it inspects to decide whether to emit issues.
  • ISSUE_JSON_ISSUES_KEY=issues names the array to iterate — one RunWhen issue per element.

The jq expression emits the envelope the runbook expects:

{
"issuesIdentified": true,
"issues": [
{
"title": "Crossplane Bucket Synced condition is not True: my-bucket",
"severity": 3,
"expected": "Synced condition should be True",
"actual": "Synced=False reason=ReconcileError message=...",
"reproduce_hint": "kubectl get buckets.storage.gcp.upbound.io my-bucket -o yaml",
"next_steps": "1. kubectl describe the bucket. 2. Inspect provider pod logs...",
"details": "..."
}
]
}

One issue per non-True condition. Ready failures get severity 2 (data-plane visible); others (Synced, etc.) get severity 3.

Useful template variables

At render time, the workspace-builder passes each template a rich context. The most useful entries for a Kubernetes CRD generation rule:

VariableValue
slx_nameGenerated SLX name, e.g. shared-cluster-<resource>-<baseName>
match_resource.resourceThe full Kubernetes object as returned by the API server
match_resource.resource.metadata.nameThe resource’s name
match_resource.kindThe Kind string (Bucket for our example)
match_resource.qualified_nameThe hierarchical ID used by the resource registry
qualifiersDict populated per your qualifiers list in the rule (resource, cluster, namespace)
cluster.nameThe Kubernetes cluster name
default_locationWorkspace default runner location
workspace.owner_emailFrom workspaceInfo.workspaceOwnerEmail
custom.kubeconfig_secret_nameFrom the custom: block in workspaceInfo.yaml

Step 4 — Wire the CodeCollection into your runner

Push the codebundle to your Git host:

Terminal window
git add codebundles/gcp-bucket-crossplane-health
git commit -m "Initial rules-only codebundle: Crossplane GCP Bucket"
git push -u origin main

Then tell your runwhen-local runner where to clone from. Edit the runner’s workspaceInfo.yaml (or the equivalent field in your Helm values if you deploy via Helm) and add your repo to the codeCollections list:

codeCollections:
# ... any existing entries
- repoURL: https://github.com/your-org/your-private-codecollection.git
ref: main

If your repo is private, provide credentials. The most common options:

  • Token-in-URL (fastest to test, least tidy):

    - repoURL: https://<user>:<token>@github.com/your-org/your-private-codecollection.git
    ref: main

    The token becomes visible to anyone with read on the workspace-builder ConfigMap. Fine for a test token you can rotate; not production hygiene.

  • A Kubernetes secret (recommended for production). Reference the secret via authUser / authToken fields, then mount the secret into the workspace-builder pod. See CodeCollection Config for the full field reference.

You do not need to add your private CC to the runner’s runner.codeCollections list — that list is only for CodeCollections that the worker pods actually execute. In this pattern the runtime (and its worker image) is provided by the rw-generic-codecollection, which each rendered SLX points at via codeBundle.repoUrl. Make sure rw-generic-codecollection is present in runner.codeCollections; your private CC only participates in the earlier discovery/rendering phase run by the workspace-builder.

Trigger a reconcile

The workspace-builder re-runs on a schedule (typically every 5 minutes). If you’re using Flux, force it:

Terminal window
flux reconcile hr runwhen-local -n <runner-namespace> --with-source

Or restart the pod directly:

Terminal window
kubectl -n <runner-namespace> rollout restart deploy/runwhen-local

Step 5 — Validate discovery

Tail the workspace-builder logs and watch for:

Terminal window
kubectl -n <runner-namespace> logs \
-l runwhen.com/component=workspace-builder \
-c workspace-builder --tail=200 -f

You should see (paraphrased):

Cloning from git source: https://.../your-private-codecollection.git
Loading generation rules from ...gcp-bucket-crossplane-health.yaml
Processing generation rule for codebundle: gcp-bucket-crossplane-health
Trying custom resource buckets.storage.gcp.upbound.io
Rendered N SLXs

Where N is the number of Buckets in your cluster.

Common errors and their meaning:

Log lineCause
fatal: Authentication failed on cloneToken missing, expired, or lacks read scope
KeyError: 'match_resource' in a templateJinja variable typo, or trying to use a variable that isn’t populated for the platform
Validation failed for ... required "outputItems"Schema drift — check the generation rule against the JSON schema
No matches for resource type buckets.storage.gcp.upbound.ioThe CRD isn’t installed on this cluster, or the plural is wrong
Forbidden ... cannot list buckets.storage.gcp.upbound.ioMissing RBAC for the workspace-builder service account
Error: (404) Reason: Not Found on a cluster-scoped CRDYou’re on runwhen-local < 0.12; upgrade to a version that includes PR #806

Browse discovered resources in the Workspace Explorer

Log tailing tells you how many SLXs rendered — but not which resources they came from or what the fully rendered YAML actually looks like. The workspace-builder pod exposes a Workspace Explorer UI on port 8000. Port-forward and open it in your browser:

Terminal window
kubectl -n <runner-namespace> port-forward \
$(kubectl -n <runner-namespace> get pod \
-l runwhen.com/component=workspace-builder \
-o name | head -n1) \
8000:8000

Then visit http://localhost:8000/explorer/. Three tabs cover different debugging questions:

  • Discovered Resources — every resource the indexers saw this reconcile, including your Crossplane Buckets. Each entry shows the resolved resource_type and the computed qualified_name that the generation rule matched against. Useful when the log says Rendered N SLXs but N is smaller than you expected — you can confirm which resources actually made it through discovery and inspect the manifest the workspace-builder is holding.
  • SLX Bundles — one card per rendered SLX, grouping its slx.yaml, sli.yaml, runbook.yaml, and overlaid Skill.md. Clicking a card shows the fully rendered YAML that gets uploaded to the RunWhen Platform, so you can catch template mistakes (wrong qualified_name, missing values, mangled configProvided) before they ship.
  • All Artifacts — a flat search across every file in the render output.

The same data is available as JSON at /explorer/api/summary, /explorer/api/artifacts, and /api/overview — handy for scripting checks in CI. See the runwhen-local Workspace Explorer docs for the full endpoint list.

Step 6 — See it in the RunWhen UI

Sign into your workspace and open Workspace Studio → Tasks. Search for bucket (or any name fragment your rule matches) to filter the tree. You’ll see one SLX per discovered Bucket, all sitting directly under KUBERNETES → DEFAULT because Crossplane managed resources are cluster-scoped (no namespace row in the tree).

Workspace Studio Tasks list filtered to "bucket" — eight discovered Crossplane GCP Bucket SLXs under KUBERNETES / DEFAULT cluster, one per bucket in the cluster

Click the eye icon on any SLX to open the Preview dialog. The four tabs give you the whole health picture:

  • Health — the SLI graph. Populates after the runner has completed at least one interval; each point is the fraction of .status.conditions[] in state True.
  • Tasks — the tasks defined in this SLX. You’ll see the single “Inspect Crossplane GCP Bucket … conditions” task with its last-run timestamp.
  • RunSessions — historical executions, once you’ve run the task at least once.
  • Metadata — everything the tags and hierarchy includes emitted: SLX name, owners, tags, and additional context.

The Metadata tab is the fastest way to confirm the workspace-builder rendered the SLX with the identity you expected:

SLX Preview Metadata tab for a Crossplane GCP Bucket — showing owners, tags (platform kubernetes, cluster default, kind Bucket, resource_type, resource_name, managed-by crossplane, provider upbound-gcp), plus resource path and qualified_name that identify the underlying Kubernetes object

The tags come from three sources you controlled:

  • platform, cluster, kind, resource_name, resource_type, and any Kubernetes labels prefixed with [k8s] — all emitted by the kubernetes-tags.yaml include.
  • The access, managed-by, and provider tags — added by your SLX template.
  • The resourcePath and qualified_name under Additional Context — a stable identity RunWhen uses to correlate this SLX with the underlying Kubernetes object.

The Tasks tab shows the runbook task ready to execute:

SLX Preview Tasks tab — a single "Inspect Crossplane GCP Bucket runwhen-nonprod-shared-grafana-backups conditions" task with a last-run timestamp

Select the task’s checkbox back in the Studio tree and click Run to execute it. When the RunSession finishes:

  • If every condition is True, the report shows the raw kubectl output and no issues are filed.
  • If any condition is False (or missing), one issue is filed per non-True condition — Ready failures at severity 2, others at severity 3 — with the reproduce hint and next-steps text baked into the jq expression.

To exercise the failure path deliberately, patch a Bucket’s spec so the provider can’t reconcile it (for example, point it at a nonexistent GCP project via a bogus ProviderConfig). Wait a reconcile interval, then re-run the task from the UI — you’ll see the issue appear with the condition detail in actual.

Complex logic: use tool-builder

When your check outgrows a single kubectl … | jq invocation — multi-step logic, cross-resource comparisons, arithmetic that jq makes ugly — swap the runtime to tool-builder. It runs an arbitrary Bash or Python script that you supply, passed as a base64-encoded string in the GEN_CMD config value. Everything else about the Custom Discovery CodeCollection pattern stays the same — your CodeCollection still ships only rules and templates, and each rendered SLX still delegates execution to the RunWhen Generic CodeCollection.

For the end-to-end authoring flow — script contract, base64 encoding step, per-resource env vars via CONFIG_ENV_MAP, secrets via SECRET_ENV_MAP, a copy-pasteable worked example, and dev → stage → prod promotion — see the Author Custom Tasks with MCP guide. The runtime’s config surface is also documented on the tool-builder Skill Template page in the RunWhen Skills Registry.

Where to go from here

You now have a repeatable pattern for turning any Kubernetes CRD into a monitored SLX with no Robot code of your own. From here:

  • Add more rules to the same CodeCollection. Anything that lives at .runwhen/generation-rules/*.yaml inside any codebundles/*/ folder gets picked up. Add a rule for bucketiammembers, hmackeys, or any other CRD in the same repo.
  • Swap the runtime. For AWS or Azure resources, point the SLI/TaskSet’s codeBundle.repoUrl at the same RunWhen Generic CodeCollection but change pathToRobot to codebundles/aws-cmd/sli.robot (see aws-cmd) or codebundles/azure-cmd/runbook.robot (see azure-cmd), and provide the corresponding CLI command in configProvided.
  • Layer in an SLO. Once the SLI has some history, add an outputItems: - type: slo entry to the rule with a burn-rate objective. See SLOs (Objectives).
  • Browse the registry for more Skill Templates. The RunWhen Skills Registry catalogs every published Skill Template across all CodeCollections. Filter to the RunWhen Generic CodeCollection to find other reusable runtimes (for example k8s-stdout-issue) that a rules-only CodeCollection can delegate to.
  • Let an MCP agent do the authoring. For custom scripts that don’t fit an existing runtime, the Author Custom Tasks with MCP guide walks through writing, live-testing, and rendering a bundle into this same CodeCollection from Cursor or Claude Desktop — with a round-trip check that keeps the base64-encoded GEN_CMD honest at PR review.
  • Manage the CodeCollection as code. The Managing Context as Code (GitOps) guide covers keeping your generation rules, tasks, and knowledge under version control end-to-end.