> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lumovi.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Write a plugin

> Give your own custom resources useful columns, a real status, related objects and one-click actions, in a short YAML file.

A plugin tells Lumovi how to show your kinds. Its views say, for each kind, which columns matter, how to tell whether an object is healthy, which facts to show, which objects it relates to, and which changes people make to it. It's a YAML file, and data only: it reads fields with the same JSONPath CRD printer columns use, and changes objects only with the patches and objects it spells out.

This guide builds a plugin for an in-house `Database` kind, step by step. Swap in your own kind's group and fields as you go.

<Info>
  The example's custom resource looks like this:

  ```yaml theme={"theme":{"light":"github-light","dark":"github-dark-default"}}
  apiVersion: platform.example.com/v1
  kind: Database
  metadata:
    name: orders
    namespace: shop
  spec:
    engine: postgres
    version: "16"
    size: 50Gi
    paused: false
    credentialsSecret: orders-credentials
  status:
    phase: Running
    endpoint: orders.shop.svc:5432
    conditions:
      - type: Ready
        status: "True"
        reason: Available
  ```

  Its operator labels each database's pods with `platform.example.com/database: orders`, and keeps its backups as `DatabaseBackup` objects labeled the same way.
</Info>

<Steps>
  <Step title="Find the kind and its fields">
    Open **API resources** in the sidebar and find your kind. Note its API group (`platform.example.com`) and kind (`Database`). Then open an object of it, and look at its **YAML** tab: that's where the paths you'll use come from.
  </Step>

  <Step title="Create the file">
    Plugins live in `~/.lumovi/plugins`. Every `.yaml` or `.yml` file there is read, and each file is one plugin.

    ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}}
    mkdir -p ~/.lumovi/plugins
    ```

    Create `~/.lumovi/plugins/databases.yaml` with the smallest plugin there is, one view of one kind:

    ```yaml databases.yaml theme={"theme":{"light":"github-light","dark":"github-dark-default"}}
    apiVersion: lumovi.dev/v1beta1
    kind: Plugin
    metadata:
      name: databases
    spec:
      version: 1.0.0
      needs:
        lumovi: 1.22.0
      groups: [platform.example.com]
      views:
        - kinds:
            - { group: platform.example.com, kind: Database }
          icon: database
    ```

    A plugin says what it is before what it shows: its own `version`, the oldest Lumovi it works with under `needs`, and the API `groups` its kinds are in. Every kind it names is in one of those groups. Its name is lowercase letters, digits and hyphens.

    In Lumovi, press <kbd>⌘</kbd><kbd>R</kbd> (Ctrl+R on Windows and Linux). **API resources** now shows `databases.yaml` under **Shown by** for the kind, and Lumovi uses the database icon for it. Everything in the next steps goes into that view, beside its `kinds`.
  </Step>

  <Step title="Add columns">
    Columns come after the name and status, and replace the ones the API server prints.

    ```yaml theme={"theme":{"light":"github-light","dark":"github-dark-default"}}
          columns:
            - { name: Engine, path: .spec.engine }
            - { name: Version, path: .spec.version }
            - { name: Size, path: .spec.size }
            - { name: Endpoint, path: .status.endpoint, default: "—" }
    ```

    `type` makes a column sort and read better: `number` right-aligns and sorts numerically, `date` reads like "2h ago" and sorts by time, `boolean` shows Yes or No, and `count` shows how many values a path finds.
  </Step>

  <Step title="Say what healthy means">
    Status rules are checked in order, and the first that applies decides the status. If none applies, Lumovi falls back to [the usual conventions](/custom-resources/overview#status).

    ```yaml theme={"theme":{"light":"github-light","dark":"github-dark-default"}}
          status:
            - when: { path: .spec.paused }
              health: neutral
              label: Paused
            - when: { path: '.status.conditions[?(@.type=="Ready")].status', equals: 'True' }
              health: healthy
              label: Ready
            - when: { path: .status.phase, in: [Provisioning, Upgrading] }
              health: progressing
              label: '{{ .status.phase }}'
            - when: { path: '.status.conditions[?(@.type=="Ready")].status', equals: 'False' }
              health: critical
              label: '{{ .status.conditions[?(@.type=="Ready")].reason ?? "Not ready" }}'
              detail: '{{ .status.conditions[?(@.type=="Ready")].message }}'
    ```

    `health` is one of `healthy`, `progressing`, `warning`, `critical` or `neutral`: it picks the color, the icon, the sort order and the filter chip. `label` is what the status says, and `detail` shows on hover. Both are templates: `{{ .path }}` puts a value in, and `??` gives a fallback when the path finds nothing.

    <Tip>
      Comparisons ignore case, so even an unquoted `equals: True`, which YAML reads as a boolean, matches Kubernetes' `"True"`.
    </Tip>
  </Step>

  <Step title="Add details">
    Details are facts shown in the detail panel's **Details** section. They take the same fields as columns.

    ```yaml theme={"theme":{"light":"github-light","dark":"github-dark-default"}}
          details:
            - { name: Engine, path: .spec.engine }
            - { name: Endpoint, path: .status.endpoint }
            - { name: Last backup, path: .status.lastBackup, type: date }
    ```
  </Step>

  <Step title="Link related objects">
    Links point to single objects, and appear under **Related** in the detail panel's **Overview**: click one to open it. `kind` is a built-in kind (`Secret`, `Pod`, `Node`…), or a kind and its group (`Certificate.cert-manager.io`). The namespace is this object's unless you set one.

    ```yaml theme={"theme":{"light":"github-light","dark":"github-dark-default"}}
          links:
            - name: Credentials
              kind: Secret
              objectName: '{{ .spec.credentialsSecret }}'
    ```

    A link whose kind or name comes out empty isn't shown.
  </Step>

  <Step title="List what belongs to it">
    Related lists are the objects of another kind that belong to this one, found by their labels or a field selector. Each is a tab in the detail panel, with the kind's usual columns.

    ```yaml theme={"theme":{"light":"github-light","dark":"github-dark-default"}}
          related:
            - name: Instances
              kind: Pod
              labels: { platform.example.com/database: '{{ .metadata.name }}' }
            - name: Backups
              kind: DatabaseBackup.platform.example.com
              labels: { platform.example.com/database: '{{ .metadata.name }}' }
    ```

    * A list of pods becomes the panel's **Pods** tab, under its own name (**Instances** here), and brings **Logs** and **Metrics** tabs for those pods.
    * Every list needs `labels` or a `fieldSelector`, like `'spec.nodeName={{ .status.nodeName }}'`.
    * Lists look in this object's namespace unless you set a `namespace`. A list whose templates come out empty isn't shown, since it would find the wrong objects.
  </Step>

  <Step title="Add actions">
    Actions patch the object, like `kubectl patch`. They show up in the object's **⋯** menu, its right-click menu and the command palette; `primary: true` also makes one a button in the detail panel.

    ```yaml theme={"theme":{"light":"github-light","dark":"github-dark-default"}}
          actions:
            - name: Pause
              when: { path: .spec.paused, notEquals: true }
              runs: at-once
              patch: { spec: { paused: true } }
              undo: { spec: { paused: false } }
              done: Paused {{ .metadata.name }}

            - name: Resume
              primary: true
              when: { path: .spec.paused, equals: true }
              runs: at-once
              patch: { spec: { paused: false } }
              undo: { spec: { paused: true } }
              done: Resumed {{ .metadata.name }}

            - name: Rotate credentials
              icon: key-round
              confirm: Applications using the old password lose their connections.
              danger: true
              patch:
                metadata:
                  annotations: { platform.example.com/rotate-at: '{{ now }}' }
              done: Asked to rotate {{ .metadata.name }}'s credentials
    ```

    * `when` offers an action only when it makes sense.
    * `undo` is a patch offered as **Undo** in the notification.
    * `confirm` asks first, with this text. Without it (and without inputs, or an object to create), the action runs at once, and has to say so: `runs: at-once`.
    * A patch can't change what's the cluster's to set, like an object's owners, finalizers, name or namespace. See [what a patch can't change](/reference/plugin-format#what-a-patch-can’t-change).
    * `{{ now }}` is the current time, as Kubernetes writes times: handy for annotations a controller watches.
  </Step>

  <Step title="Ask for a value">
    An action can ask for values before it runs: text, a number, or a choice. Its templates use them as `{{ input.name }}`.

    ```yaml theme={"theme":{"light":"github-light","dark":"github-dark-default"}}
            - name: Resize
              icon: scaling
              inputs:
                - name: size
                  label: New size
                  default: '{{ .spec.size }}'
              patch: { spec: { size: '{{ input.size }}' } }
              undo: { spec: { size: '{{ .spec.size }}' } }
              done: Resized {{ .metadata.name }} to {{ input.size }}
    ```

    **Resize…** opens a dialog with a **New size** field, starting at the current size. The `undo` is filled in from the database as it was before, so **Undo** puts the old size back. A number input (`type: number`) gives a number, and a choice (`type: choice`) is one of its `options`, or of the values a `from` path finds.
  </Step>

  <Step title="Create objects">
    An action can create another object instead of patching this one, with `create` in place of `patch`:

    ```yaml theme={"theme":{"light":"github-light","dark":"github-dark-default"}}
            - name: Back up now
              icon: archive
              create:
                apiVersion: platform.example.com/v1
                kind: DatabaseBackup
                metadata:
                  generateName: '{{ .metadata.name }}-'
                  labels: { platform.example.com/database: '{{ .metadata.name }}' }
                spec:
                  database: '{{ .metadata.name }}'
              done: Started a backup of {{ .metadata.name }}
    ```

    **Back up now…** shows the backup as YAML before it's created, with the name it will get, in the database's namespace. Once it's created, **Open** in the notification opens it. A value that's a whole template copies what it finds, so `spec: '{{ .spec }}'` would copy the object's whole spec. Creating can't be undone from the notification.

    Every action checks your permissions first: it needs `patch` on the kind (or on its `status`, with `subresource: status`), or `create` on the kind it creates. It shows the equivalent command, `kubectl patch` or `kubectl create`, and can't run in a read-only cluster.
  </Step>

  <Step title="Give it a place in the sidebar">
    A place in the sidebar gathers your kinds under one entry, with everything of them in one list and a tab for each. Add a label and the kinds to the plugin's `spec`, beside `views`:

    ```yaml theme={"theme":{"light":"github-light","dark":"github-dark-default"}}
    spec:
      label: Platform
      icon: database
      kinds:
        - { group: platform.example.com, kind: Database }
        - { group: platform.example.com, kind: DatabaseBackup }
      views:
        # … as above
    ```

    See [Plugins](/custom-resources/plugins).
  </Step>

  <Step title="Reload and check">
    Press <kbd>⌘</kbd><kbd>R</kbd>. If something's wrong, **All plugins**, under **Plugins** in the sidebar, says so on the file's row, **Not in use. 1 problem to fix in the file.**, with the place in it:

    ```text theme={"theme":{"light":"github-light","dark":"github-dark-default"}}
    spec.views[0].status[1].health: is required
    ```

    A plugin is used whole or not at all: with a problem anywhere in its file, none of it is used, and its kinds fall back to Lumovi's own views or the API server's columns until you fix it.
  </Step>
</Steps>

## The whole plugin

<Accordion title="databases.yaml" icon="file-code">
  ```yaml databases.yaml theme={"theme":{"light":"github-light","dark":"github-dark-default"}}
  apiVersion: lumovi.dev/v1beta1
  kind: Plugin
  metadata:
    name: databases
  spec:
    version: 1.0.0
    needs:
      lumovi: 1.22.0
    groups: [platform.example.com]
    label: Platform
    icon: database
    kinds:
      - { group: platform.example.com, kind: Database }
      - { group: platform.example.com, kind: DatabaseBackup }

    views:
      - kinds:
          - { group: platform.example.com, kind: Database }
        icon: database

        columns:
          - { name: Engine, path: .spec.engine }
          - { name: Version, path: .spec.version }
          - { name: Size, path: .spec.size }
          - { name: Endpoint, path: .status.endpoint, default: "—" }

        status:
          - when: { path: .spec.paused }
            health: neutral
            label: Paused
          - when: { path: '.status.conditions[?(@.type=="Ready")].status', equals: 'True' }
            health: healthy
            label: Ready
          - when: { path: .status.phase, in: [Provisioning, Upgrading] }
            health: progressing
            label: '{{ .status.phase }}'
          - when: { path: '.status.conditions[?(@.type=="Ready")].status', equals: 'False' }
            health: critical
            label: '{{ .status.conditions[?(@.type=="Ready")].reason ?? "Not ready" }}'
            detail: '{{ .status.conditions[?(@.type=="Ready")].message }}'

        details:
          - { name: Engine, path: .spec.engine }
          - { name: Endpoint, path: .status.endpoint }
          - { name: Last backup, path: .status.lastBackup, type: date }

        links:
          - name: Credentials
            kind: Secret
            objectName: '{{ .spec.credentialsSecret }}'

        related:
          - name: Instances
            kind: Pod
            labels: { platform.example.com/database: '{{ .metadata.name }}' }
          - name: Backups
            kind: DatabaseBackup.platform.example.com
            labels: { platform.example.com/database: '{{ .metadata.name }}' }

        actions:
          - name: Pause
            when: { path: .spec.paused, notEquals: true }
            runs: at-once
            patch: { spec: { paused: true } }
            undo: { spec: { paused: false } }
            done: Paused {{ .metadata.name }}
          - name: Resume
            primary: true
            when: { path: .spec.paused, equals: true }
            runs: at-once
            patch: { spec: { paused: false } }
            undo: { spec: { paused: true } }
            done: Resumed {{ .metadata.name }}
          - name: Rotate credentials
            icon: key-round
            confirm: Applications using the old password lose their connections.
            danger: true
            patch:
              metadata:
                annotations: { platform.example.com/rotate-at: '{{ now }}' }
            done: Asked to rotate {{ .metadata.name }}'s credentials
          - name: Resize
            icon: scaling
            inputs:
              - name: size
                label: New size
                default: '{{ .spec.size }}'
            patch: { spec: { size: '{{ input.size }}' } }
            undo: { spec: { size: '{{ .spec.size }}' } }
            done: Resized {{ .metadata.name }} to {{ input.size }}
          - name: Back up now
            icon: archive
            create:
              apiVersion: platform.example.com/v1
              kind: DatabaseBackup
              metadata:
                generateName: '{{ .metadata.name }}-'
                labels: { platform.example.com/database: '{{ .metadata.name }}' }
              spec:
                database: '{{ .metadata.name }}'
            done: Started a backup of {{ .metadata.name }}
  ```
</Accordion>

## Good to know

* **Quote templates and filters in YAML.** A value that starts with `{{` must be in quotes, or YAML reads it as a map. Quote paths with brackets or double quotes in them too, like `'.status.conditions[?(@.type=="Ready")].status'`.
* **A kind is one plugin's.** A plugin of yours shows kinds no other plugin has. To change how one of Lumovi's plugins shows its kinds, copy its file from [Lumovi's plugins](https://github.com/Lumovi/Lumovi/tree/main/plugins), keep its `metadata.name`, and edit it: yours is used in its place, all of it.
* **Every kind of a group.** `kind: '*'` with a `group` covers every kind of that group, and the plugin then has that group to itself. A view for a kind itself wins over one for its group. See [Whose a kind is](/reference/plugin-format#whose-a-kind-is).
* **A tool's kinds, not Kubernetes'.** Pods, Secrets and the other kinds Lumovi has pages for can't be in a plugin. One it has no page for, like a Lease, a plugin can show without actions, named under [`shows`](/reference/plugin-format#kubernetes’-own-kinds).
* **Another folder.** Set `LUMOVI_PLUGINS_DIR` to read plugins from somewhere else, like a folder your team shares in a Git repository.
* **Links work too.** A link in the folder, named with `.yaml` or `.yml` at the end, is read like the file it points to, so you can link a plugin from elsewhere. A link to a file that's gone can't be read: **All plugins** lists it as not in use, with why, like `It couldn’t be read: ENOENT: no such file or directory, …`, and the other files still load.
* **Limits.** Lumovi reads up to 200 files from the folder, each up to 256 KB. Its subfolders aren't read, except a [plugin with code](/custom-resources/plugins#your-own-plugins-with-code), in the desktop app.

<Note>
  **In your cluster:** plugins everyone sees come from the chart's `plugins` value, one entry per file. People can't add their own from the browser. See [Plugins for everyone](/server/plugins).
</Note>

<Columns cols={2}>
  <Card title="Plugin format" icon="braces" href="/reference/plugin-format">
    Every field, path, condition and template, in one place.
  </Card>

  <Card title="Included plugins" icon="blocks" href="/custom-resources/included-plugins">
    Examples from cert-manager, Argo CD, Flux and more.
  </Card>
</Columns>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.