StackPanel
Extensions

Writing Extensions

Build reusable modules that add capabilities to any Stackpanel project

Extensions are the primary way to package reusable functionality in Stackpanel. An extension is a Nix module that registers itself in the extension system and uses core Stackpanel features—file generation, scripts, services, packages, and more—to provide integrated capabilities.

If you've written any Nix configuration for Stackpanel, you already know 90% of what you need. An extension is just a module with some extra metadata so the registry and Studio can discover and display it.

Anatomy of an Extension

Every extension does three things:

  1. Declares options — configuration knobs for the extension
  2. Registers itself — metadata for discovery, categorization, and UI
  3. Uses core features — contributes files, scripts, packages, services, etc.

Here's a minimal example:

# nix/stackpanel/my-extension/my-extension.nix
{ pkgs, lib, config, ... }:
let
  cfg = config.stackpanel.my-extension;
in
{
  options.stackpanel.my-extension = {
    enable = lib.mkOption {
      type = lib.types.bool;
      default = false;
      description = "Enable my extension";
    };

    greeting = lib.mkOption {
      type = lib.types.str;
      default = "Hello from my extension!";
      description = "A greeting message";
    };
  };

  config = lib.mkIf cfg.enable {
    # Register the extension
    stackpanel.extensions.my-extension = {
      name = "My Extension";
      enabled = true;
      builtin = true;
      description = "An example extension";
      tags = [ "example" ];

      features = {
        scripts = true;
        packages = true;
      };
    };

    # Use core features
    stackpanel.scripts."my-ext:hello" = {
      exec = "echo '${cfg.greeting}'";
      description = "Print a greeting";
    };

    stackpanel.devshell.packages = [ pkgs.cowsay ];

    stackpanel.motd.features = [ "My Extension" ];
  };
}

Enable it in your config:

stackpanel.my-extension = {
  enable = true;
  greeting = "Howdy, partner!";
};

And you've got a my-ext:hello command in your shell, cowsay on your PATH, and "My Extension" listed in the welcome message.

Extension Registration

The stackpanel.extensions.<name> attribute set is how Stackpanel discovers your extension. It controls what appears in Studio, how the extension is categorized, and what features it advertises.

Required Fields

stackpanel.extensions.my-extension = {
  name = "My Extension";          # Human-readable display name
  enabled = true;                  # Whether the extension is active
};

Optional Fields

stackpanel.extensions.my-extension = {
  name = "My Extension";
  enabled = true;

  # Identity
  description = "What this extension does";
  builtin = false;                 # true for extensions shipped with Stackpanel
  category = "EXTENSION_CATEGORY_DEVELOPMENT";
  tags = [ "tooling" "linting" ];
  priority = 100;                  # Load order (lower = earlier)
  dependencies = [ "other-ext" ];  # Required extensions

  # Feature flags (what core features this uses)
  features = {
    files = true;
    scripts = true;
    tasks = false;
    secrets = false;
    shell-hooks = false;
    packages = true;
    services = false;
    checks = false;
  };

  # Source (for non-builtin extensions)
  source = {
    type = "EXTENSION_SOURCE_TYPE_GITHUB";
    repo = "myorg/stackpanel-my-ext";
    ref = "main";
  };
};

Categories

Use categories to group extensions in the Studio UI:

CategoryUse Case
EXTENSION_CATEGORY_INFRASTRUCTUREAWS, cloud resources, networking
EXTENSION_CATEGORY_CI_CDGitHub Actions, deployment pipelines
EXTENSION_CATEGORY_DATABASEDatabase management and tooling
EXTENSION_CATEGORY_SECRETSSecret and variable management
EXTENSION_CATEGORY_DEPLOYMENTDeployment tools and platforms
EXTENSION_CATEGORY_DEVELOPMENTDev tools, linters, formatters
EXTENSION_CATEGORY_MONITORINGLogging, metrics, observability
EXTENSION_CATEGORY_INTEGRATIONThird-party service integrations

Core Features Available to Extensions

Extensions compose core Stackpanel features to build integrated functionality. Here's what you can use:

File Generation

Generate files into the project workspace. Multiple extensions can contribute to the same file through the module system.

stackpanel.files.entries."path/to/generated-file.ts" = {
  text = ''
    // Generated by my-extension
    export const setting = "${cfg.some-setting}";
  '';
};

stackpanel.files.entries.".gitignore" = {
  type = "line-set";
  content = [ ".my-extension-cache" ];
};

Scripts

Add CLI commands to the devshell. Use the <extension-name>:<command> namespace convention to avoid collisions:

# Inline script
stackpanel.scripts."my-ext:run" = {
  exec = "echo 'Running with ${cfg.some-setting}'";
  description = "Run my extension command";
};

# File-based script (recommended for anything longer than a one-liner)
stackpanel.scripts."my-ext:deploy" = {
  path = ./src/scripts/deploy.sh;
  description = "Deploy using my extension";
  runtimeInputs = [ pkgs.awscli2 pkgs.jq ];
  env.MY_SETTING = cfg.some-setting;
};

File-based scripts get better editor support and are easier to test in isolation. Use the env option to pass Nix-evaluated configuration values into the script.

Packages

Add tools to the devshell PATH:

stackpanel.devshell.packages = [
  pkgs.some-tool
  pkgs.another-tool
];

Services

Register background services managed by process-compose:

stackpanel.services."my-ext-server" = {
  enable = true;
  description = "My extension background server";
  command = "${pkgs.my-tool}/bin/my-tool serve --port ${toString cfg.port}";
  readiness_probe = {
    http_get = { port = cfg.port; path = "/health"; };
    initial_delay_seconds = 2;
  };
};

Shell Hooks

Run code on shell entry:

stackpanel.devshell.hooks.my-extension = [
  ''
    echo "My extension is active"
  ''
];

Tasks

Define runnable tasks:

stackpanel.tasks."my-ext:setup" = {
  description = "Initial setup for my extension";
  exec = "my-tool init --config ${cfg.configPath}";
};

MOTD

Register features in the shell welcome message:

stackpanel.motd.features = [ "My Extension" ];

File-Based Scripts with srcDir

For extensions with multiple scripts and health checks, use the srcDir option to enable auto-discovery:

nix/stackpanel/my-extension/
├── my-extension.nix
└── src/
    ├── scripts/
    │   ├── deploy.sh        # → my-ext:deploy
    │   ├── build.sh         # → my-ext:build
    │   └── status.sh        # → my-ext:status
    └── checks/
        └── health.sh        # → my-ext:health

Register the srcDir in your extension metadata:

stackpanel.extensions.my-extension = {
  name = "My Extension";
  enabled = true;
  srcDir = ./src;  # Enables auto-discovery
  # ...
};

Scripts in src/scripts/ are automatically namespaced with the extension name. A file called deploy.sh becomes the command my-ext:deploy. Health checks in src/checks/ are registered as Stackpanel health checks.

This approach is recommended over inline scripts because:

  • Better editor support (syntax highlighting, shellcheck, linting)
  • Easier to test scripts in isolation
  • Cleaner separation of Nix configuration and shell logic
  • Scripts become Nix derivations (immutable, cached)

Studio Panels

Extensions can define UI panels that appear in Studio:

stackpanel.extensions.my-extension = {
  name = "My Extension";
  enabled = true;

  panels = [
    {
      id = "my-ext-status";
      title = "My Extension Status";
      type = "PANEL_TYPE_STATUS";
      order = 1;
      fields = [
        {
          name = "metrics";
          type = "FIELD_TYPE_STRING";
          value = builtins.toJSON [
            { label = "Setting"; value = cfg.some-setting; status = "ok"; }
            { label = "Version"; value = "1.2.3"; status = "ok"; }
          ];
        }
      ];
    }
  ];
};

Panel Types

TypeDescriptionUse Case
PANEL_TYPE_STATUSKey-value status displayShow configuration state, health status
PANEL_TYPE_APPS_GRIDGrid of applicationsDisplay managed apps with status indicators
PANEL_TYPE_FORMConfiguration formLet users edit extension settings in Studio
PANEL_TYPE_TABLETabular dataLists of items, log entries, etc.
PANEL_TYPE_CUSTOMCustom React componentAdvanced UIs that need full control

Conditional Configuration with mkIf

Always wrap your config block with lib.mkIf cfg.enable so the extension does nothing when disabled:

config = lib.mkIf cfg.enable {
  # Everything here only takes effect when enable = true
  stackpanel.extensions.my-extension = { ... };
  stackpanel.scripts."my-ext:run" = { ... };
  stackpanel.devshell.packages = [ ... ];
};

This ensures that disabling the extension cleanly removes all of its contributions—packages, scripts, generated files, services, everything.

Practical Example: Oxlint Extension

Here's a real-world pattern based on the builtin oxlint extension:

{ pkgs, lib, config, ... }:
let
  cfg = config.stackpanel.oxlint;
in
{
  options.stackpanel.oxlint = {
    enable = lib.mkOption {
      type = lib.types.bool;
      default = false;
      description = "Enable oxlint for fast JavaScript/TypeScript linting";
    };

    config-file = lib.mkOption {
      type = lib.types.str;
      default = "oxlint.json";
      description = "Path to the oxlint configuration file";
    };
  };

  config = lib.mkIf cfg.enable {
    stackpanel.extensions.oxlint = {
      name = "Oxlint";
      enabled = true;
      builtin = true;
      category = "EXTENSION_CATEGORY_DEVELOPMENT";
      tags = [ "linting" "javascript" "typescript" ];
      srcDir = ./src;

      features = {
        packages = true;
        scripts = true;
        files = true;
      };

      panels = [
        {
          id = "oxlint-status";
          title = "Oxlint";
          type = "PANEL_TYPE_STATUS";
          order = 10;
          fields = [
            {
              name = "metrics";
              type = "FIELD_TYPE_STRING";
              value = builtins.toJSON [
                { label = "Config"; value = cfg.config-file; status = "ok"; }
              ];
            }
          ];
        }
      ];
    };

    stackpanel.devshell.packages = [ pkgs.oxlint ];

    stackpanel.scripts."oxlint:lint" = {
      exec = "oxlint --config ${cfg.config-file} .";
      description = "Run oxlint on the project";
    };

    stackpanel.scripts."oxlint:fix" = {
      exec = "oxlint --config ${cfg.config-file} --fix .";
      description = "Run oxlint with auto-fix";
    };

    stackpanel.files.entries.".gitignore" = {
      type = "line-set";
      content = [ ".oxlint-cache" ];
    };

    stackpanel.motd.features = [ "Oxlint" ];
  };
}

Extension Types

Builtin Extensions

Shipped with Stackpanel itself. These live in nix/stackpanel/ and set builtin = true. If you're contributing to the Stackpanel project, this is what you'd write.

Local Extensions

Defined in your project's Nix configuration. Good for project-specific automation that doesn't need to be shared:

stackpanel.extensions.my-project-tooling = {
  name = "My Project Tooling";
  enabled = true;
  source.type = "EXTENSION_SOURCE_TYPE_LOCAL";
  source.path = "./nix/extensions/my-tooling.nix";
};

External Extensions

Installed from GitHub or other sources. These are distributed as flake inputs:

# flake.nix
inputs.my-extension.url = "github:someorg/stackpanel-my-extension";

# In your flake modules
imports = [
  inputs.my-extension.flakeModules.default
];

The extension registers itself when imported—users just need to enable it:

stackpanel.my-extension.enable = true;

Best Practices

  1. Always use mkIf — Wrap your entire config block so the extension is inert when disabled.
  2. Namespace scripts — Use the <ext-name>:<command> convention to avoid collisions with other extensions.
  3. Use file-based scripts — Prefer path = ./src/scripts/foo.sh over inline exec for anything beyond a one-liner.
  4. Set srcDir — Enable auto-discovery for scripts and health checks.
  5. Declare feature flags — Tell the registry which core features your extension uses.
  6. Provide sensible defaults — Extensions should work with minimal configuration.
  7. Add MOTD entries — Help users discover your extension's commands on shell entry.
  8. Add status panels — Give users visibility into extension state through Studio.
  9. Pass config via env — Use env.MY_VAR = cfg.some-value to pass Nix values into file-based scripts.
  10. Document options — Every mkOption should have a meaningful description.

Extensions are just Nix modules with metadata. If you already know how to write Stackpanel config, you already know how to write extensions. The registration metadata is the only new concept.

Reference

On this page