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:
- Declares options — configuration knobs for the extension
- Registers itself — metadata for discovery, categorization, and UI
- 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:
| Category | Use Case |
|---|---|
EXTENSION_CATEGORY_INFRASTRUCTURE | AWS, cloud resources, networking |
EXTENSION_CATEGORY_CI_CD | GitHub Actions, deployment pipelines |
EXTENSION_CATEGORY_DATABASE | Database management and tooling |
EXTENSION_CATEGORY_SECRETS | Secret and variable management |
EXTENSION_CATEGORY_DEPLOYMENT | Deployment tools and platforms |
EXTENSION_CATEGORY_DEVELOPMENT | Dev tools, linters, formatters |
EXTENSION_CATEGORY_MONITORING | Logging, metrics, observability |
EXTENSION_CATEGORY_INTEGRATION | Third-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:healthRegister 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
| Type | Description | Use Case |
|---|---|---|
PANEL_TYPE_STATUS | Key-value status display | Show configuration state, health status |
PANEL_TYPE_APPS_GRID | Grid of applications | Display managed apps with status indicators |
PANEL_TYPE_FORM | Configuration form | Let users edit extension settings in Studio |
PANEL_TYPE_TABLE | Tabular data | Lists of items, log entries, etc. |
PANEL_TYPE_CUSTOM | Custom React component | Advanced 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
- Always use
mkIf— Wrap your entireconfigblock so the extension is inert when disabled. - Namespace scripts — Use the
<ext-name>:<command>convention to avoid collisions with other extensions. - Use file-based scripts — Prefer
path = ./src/scripts/foo.shover inlineexecfor anything beyond a one-liner. - Set
srcDir— Enable auto-discovery for scripts and health checks. - Declare feature flags — Tell the registry which core features your extension uses.
- Provide sensible defaults — Extensions should work with minimal configuration.
- Add MOTD entries — Help users discover your extension's commands on shell entry.
- Add status panels — Give users visibility into extension state through Studio.
- Pass config via
env— Useenv.MY_VAR = cfg.some-valueto pass Nix values into file-based scripts. - Document options — Every
mkOptionshould have a meaningfuldescription.
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
- Using Extensions for how users install and configure extensions
- Flake Inputs for declaring and auto-installing required flake inputs
- Extension Registry for discovering and distributing extensions
- Core Concepts → Module System for the underlying Nix module system
- Options Reference → Extensions for all extension-related options