Skip to main content
Secrets management shows a pattern: an on-activate hook calls out to a secret store and exports the result as an environment variable. That pattern works, but every environment that uses it hand-writes the same retrieval script. Plugins let you package that script once, as a regular installable package, and configure it per environment through a dedicated [plugins] section of the manifest. Anyone who installs the package gets the retrieval logic; they only need to supply the configuration. Secrets retrieval is the use case that motivated plugins, and this page anchors on it. But [plugins] itself is general-purpose: Flox stores whatever data you put there without interpreting it, so a plugin can use it for anything. See Beyond secrets for other examples. There are two ways to extend Flox:
  • Environment plugins are packages installed into an environment that take part in its lifecycle — a profile.d script at activation, or a session-wrap hook around the whole session, which is how sandboxing works — and are configured per environment through the manifest. Most of this page is about them.
  • Subcommand extensions add commands to the flox CLI itself: an executable flox-<name> becomes flox <name>. They are installed per user, belong to no environment, and run only when you invoke them.
Plugins are experimental and under active development. Expect much of what this page describes to change in future releases. Plugins require a schema-version of "1.14.0" or higher in the manifest; the session-wrap hook additionally requires "1.16.0" and a feature flag, and is currently prototype-only. Subcommand extensions are a beta feature behind the features.beta flag.

How plugins work

A plugin has two halves:
  • Configuration lives in the manifest, under [plugins.<plugin-name>]. Flox treats it as opaque data — any keys, any values — and stores it without validating its shape.
  • Behavior lives in a package, at well-known paths inside its output:
    • a script in etc/profile.d/, sourced during activation — the standard way packages hook into shell setup, and the only payload most plugins need. Flox sources every installed package’s profile.d scripts before running your manifest’s hook.on-activate; see Activating environments for where this fits in the activation timeline.
    • optionally, a session-wrap hook executable under etc/flox/hooks/, which lets the plugin run the entire activation session under its control. See Lifecycle hooks.
A plugin’s profile.d script reads its own configuration with the flox_plugin_data shell function, which Flox provides during activation. A hook executable receives the same table through a context file instead, since it runs outside the activation shell. Nothing else ties a package to a plugin — it’s a naming convention, not a manifest field that marks a package as one.

The environment lifecycle

A Flox environment moves through phases — it’s created and edited, locked and built, activated, attached to by additional shells, and eventually deactivated. Two mechanisms let a plugin participate: Further hooks for the other phases were prototyped on the flox/flox branch prototype/sandbox-plugins and are deferred until something ships that needs them. Subcommand extensions sit outside this table: they extend the CLI rather than an environment, so there is no phase at which Flox dispatches them — you run them.

Installing and configuring a plugin

Installing a plugin is the same as installing any package, plus one step: adding its configuration table. Suppose a vault-secrets package provides a plugin that wraps HashiCorp Vault. Install it, then add a [plugins.vault-secrets] table following the convention its author documented — here, a flat map of environment variable name to secret path:
Run flox activate, and GH_TOKEN and DB_PASSWORD are exported, fetched fresh from Vault — the same result as a hand-written on-activate hook, except the retrieval logic now ships with the package instead of living in your manifest.
Flox doesn’t check that an installed package actually provides the plugin named in [plugins.<name>], and a plugin’s script can read your entire manifest, not just its own table. Trust plugin packages the way you’d trust any package that runs code during activation.
Add a [plugins.<name>] table without installing a matching plugin, and nothing happens — Flox doesn’t cross-reference the two. What happens if you install a plugin but skip its configuration is up to the plugin: a script that lets flox_plugin_data’s failure propagate aborts activation; one that checks for it explicitly can warn and continue instead. See Writing a plugin for both patterns. A plugin that uses the session-wrap hook needs one more piece: a declaration in the [plugin-hooks] section. Unlike [plugins.<name>] data, hook participation is cross-referenced — a declaration without a matching installed package fails the activation, and a shipped hook without a declaration is ignored with a warning.

Writing a plugin

Any package can be a plugin. What makes it one is a profile.d script that reads its own manifest data:
etc/profile.d/0900_vault-secrets.sh
flox_plugin_data <plugin-name> prints the [plugins.<plugin-name>] table from the locked manifest as compact JSON, or fails if the table is missing. Parse the JSON however you like — ${_jq:-jq} reaches for the jq that Flox’s own activation helpers already resolved into $_jq before falling back to a jq on PATH, so your script doesn’t need to depend on one itself. The script above fails hard: _data="$(flox_plugin_data vault-secrets)" is a plain assignment, and profile.d scripts run under set -e, so a missing table aborts activation. That’s a choice, not something Flox enforces — wrap the call and check its exit status yourself to degrade gracefully instead, for example printing a warning and leaving a variable unset when a secret is optional. Fail hard for a plugin the environment can’t run without; fail soft for one it can. A few conventions to follow when naming and scoping a plugin:
  • Name it after your package. The plugin name doesn’t have to match the package’s install ID or pkg-path, but matching pkg-path makes the connection obvious to anyone reading the manifest. For a plugin that declares a session-wrap hook the alignment is mandatory: the [plugin-hooks] value, the package’s install ID, and the shipped hook filename must all carry the same name.
  • Read only your own table. Nothing stops a script from reading the whole manifest, but Flox won’t enforce that boundary for you — stick to [plugins.<your-plugin-name>].
  • Order your script deliberately. profile.d scripts run in filename order. Flox’s own setup scripts currently top out around 0800; a 0900 prefix runs after them, and after any other plugin your logic depends on.
The same script runs during flox build too, so [build] commands can read your plugin’s exported variables — not just interactive and flox activate -- <cmd> sessions.

Lifecycle hooks

Lifecycle hooks are a prototype: they exist on a development branch of Flox, not in any release. They require a manifest schema-version of "1.16.0" and an explicit feature flag: flox config --set features.plugin_hooks true (or export FLOX_FEATURES_PLUGIN_HOOKS=true). With the flag off, a [plugin-hooks] declaration is ignored with a warning and the activation proceeds unwrapped — so an environment that declares a hook stays usable for teammates who haven’t opted in.
profile.d scripts cover one moment in the lifecycle: environment setup at activation start. A lifecycle hook is a file at a well-known path inside the plugin package, discovered in the rendered environment and dispatched by Flox at the right moment. One hook exists today: session-wrap, which runs before the activation session starts. With the feature flag off, the warning names the flag to enable:

The hook tree

Per-plugin files inside per-hook directories merge across packages exactly like profile.d does. One caveat is load-bearing: two packages shipping an identical leaf filename is a hard build failure, so naming the hook file after the plugin (<plugin-name>) is a requirement, not tidiness.

Declaring hooks: [plugin-hooks]

A hook doesn’t run just because a package ships it. The environment’s manifest must opt in, through a typed, top-level section with a single key:
The value names a plugin — the install ID of a package that must ship etc/flox/hooks/session-wrap.d/<plugin-name>. Unknown keys in [plugin-hooks] fail at parse time, and session-wrap is typed as a single string, so two wrappers are unrepresentable in one manifest. At activation, Flox verifies the binding in both directions. Each of the following fails the activation with the message shown:
  • The declared hook file is missing — the plugin isn’t installed, or its package doesn’t ship the hook:
  • A hook file exists, but no installed package has the declared install ID:
  • The hook file is shipped by a different package than the declared plugin’s — a look-alike package shadowing a plugin’s name:
  • The hook file isn’t executable:
In the other direction, a shipped hook that isn’t declared is ignored with a warning naming the fix:
Why the declaration exists at all: installing any package already concedes code execution at activation — every package’s profile.d script runs with your privileges. The declaration is not a code-execution boundary. What it gates is one specific power a profile.d script doesn’t have: session capture — a session-wrap hook execs your terminal session under code the plugin controls. profile.d scripts have no such power, so they stay undeclared. Declaring a session wrapper means “activating this environment hands the session to that plugin”. Flox makes sure that’s always something you wrote, and something you agree to:
  • Only the top-level manifest’s [plugin-hooks] section is effective. When one environment includes another, an included manifest’s [plugin-hooks] section is dropped during composition, with a notice naming the include:
    Plugin data tables flow through includes; hook participation does not — a declaration can never arrive from a manifest you didn’t author. To enable an included environment’s plugin hook, restate the declaration in your own manifest.
  • Auto-activation asks first. A directory whose environment declares a session wrapper is never activated in place by the prompt hook. Instead, entering it prompts before handing over the session, and the default is No:
    Only y or yes accepts; bare Enter declines. The answer is remembered for the current shell visit — leaving the directory clears it, and re-entering asks again. The prompt appears even for directories you’ve allowed with flox activate allow, since a prior allow may predate the wrap declaration; an unregistered directory prompts only while auto_activate is prompt, and a denied one never does. Accepting runs flox activate --dir <path> as a foreground session rather than the usual in-place activation: when the session exits you are back in your original shell, with nothing activated. On fish and tcsh, or without a terminal, no prompt is shown — a notice points at running flox activate yourself instead:

The hook protocol

Flox writes a JSON context file readable only by you (mode 0600, in Flox’s temporary directory) and invokes the hook with five environment variables:
  • FLOX_HOOK_CTX — path to the context file
  • FLOX_HOOK — the hook kind, session-wrap
  • FLOX_PLUGIN_NAME — the plugin whose hook is being invoked
  • FLOX_BIN — the invoking flox binary, for hooks that need to run Flox commands themselves (an image bake with flox containerize, say)
  • FLOX_HOOK_JQ — a jq bundled with Flox, so shell-scripted hooks can parse the context without depending on one
The context is versioned (ctx_version, currently 1) and carries plugin_table — the plugin’s own [plugins.<name>] table as verbatim JSON, or null when the manifest has no table for it. This is how a hook executable reads its configuration: it runs outside the activation shell, so the flox_plugin_data function isn’t available to it. Hooks are language-agnostic — a hook with real logic can be a compiled binary shipped in the package; simple ones stay shell. The hook inherits your environment, working directory, and stdio, and runs before any activation setup — which on macOS can mean bash 3.2 for a shell hook, so keep it compatible.

session-wrap

The hook runs the entire activation session under the plugin’s control. Flox dispatches it during flox activate, after the environment is locked, built, and rendered (hooks are discovered in the rendered environment), immediately before the session would start. The hook composes whatever boundary it implements — an OS sandbox, a container — and execs the activation inside it; on success it never returns. The hook replaces the flox process, so its exit status becomes the activation’s: a hook that can’t hand off must exit non-zero and say why on stderr. There is no “decline and continue unwrapped” path — an environment that declares a wrapper either activates wrapped or not at all. The context a session-wrap hook receives: The context gives a wrapper two ways to re-enter the activation. inner_argv is the flox activate invocation itself — an argv starting with the absolute path of the flox binary — sufficient for a boundary that shares the host filesystem and can simply re-exec flox under a wrapper process. invocation_type is the structured form of how you invoked activation — "interactive", {"shellcommand": "<string>"} for -c, or {"execcommand": ["<cmd>", "<arg>", ...]} for -- <cmd> — from which a container boundary composes its own in-boundary command. Rules Flox enforces around the wrap:
  • One wrapper per manifest, structurally (see the schema above).
  • Re-entry is detected, nesting is refused. The hook exports _FLOX_SESSION_WRAPPED=<wrap_scope> on the wrapped process. When the same environment re-activates inside its own boundary, the marker matches and Flox skips the wrap; activating a different wrapping environment inside it is an error, because nested boundaries are unsupported:
  • In-place activation is refused. eval "$(flox activate)" cannot hand your current shell to a wrapper:
  • Ephemeral activations skip the wrap. The ephemeral activation that flox services start and flox services restart perform to launch a new process-compose instance is never wrapped.
  • Stdio is inherited but not guaranteed to be a terminal — flox activate -- cmd | tee reaches the hook with stdout a pipe. A hook that wants to prompt must check the tty state the context provides and talk to the terminal directly (/dev/tty or stderr), never stdout.

Writing and testing a hook

Hooks are testable without publishing anything. Build the plugin package (a [build] target whose output ships the hook tree), install it into a test environment by store path, declare it, and activate:
The cache directory blessed for plugin state is <project>/.flox/cache/plugins/<plugin-name>/ — it survives across activations and is not committed.

Debugging a plugin

Activation runs plugin scripts silently. When one doesn’t do what you expect, pass -v to flox activate — verbose mode traces the activation script command by command, including every profile.d script as it’s sourced:
-- true activates, runs true, and exits — a quick way to capture a trace without entering a subshell. The trace goes to stderr, hence the redirect. The trace answers the questions that come up while writing a plugin:
  • Did my script run, and when? Each + source line appears in filename order — Flox’s own setup scripts first, then plugin scripts. If no profile.d lines appear at all, either the environment was already active somewhere and this activation attached instead of re-running setup — exit the other activation first — or the environment is in run mode, which skips package profile.d scripts entirely.
  • What data did it receive? Drop the grep and the trace shows every command inside your script as it executes, including what flox_plugin_data printed:
    This is the only window into that call — flox_plugin_data exists only while profile.d scripts are being sourced, so you can’t run it by hand in the activated shell afterward.
  • Which command failed? profile.d scripts run under set -e, so when a plugin aborts activation, the last traced command before the failure is the one that caused it.
The verbose trace prints every command with its arguments fully expanded — for a secrets plugin, that includes the fetched secret values. Treat the output like the secrets themselves: don’t paste it into an issue or capture it in CI logs.
A session-wrap hook has a different debugging surface, since it runs outside the traced activation script:
  • Pass -vv (debug-level logging) and Flox logs the dispatch — exec'ing session-wrap hook — with the plugin name and the resolved hook path.
  • The hook inherits your terminal, so anything it writes to stderr reaches you directly.

Plugin data in composed environments

When one environment includes another, and both configure the same plugin, the including environment’s table wins outright — Flox doesn’t merge the two tables key by key:
The composed environment ends up with only DB_PASSWORD, not GH_TOKEN plus an overridden DB_PASSWORD. Flox warns when this happens — a partial, key-by-key merge could hand a plugin a table its author never intended. If you compose environments that share a plugin, restate every key you want to keep in the including environment’s table. [plugin-hooks] sections don’t merge at all: an included environment’s declaration is dropped, as described in Consent and composition.

Subcommand extensions

Subcommand extensions are a beta feature, available since Flox 1.14.1 behind a feature flag: flox config --set features.beta true, or export FLOX_FEATURES_BETA=true. While in beta, flox extension is hidden from flox --help, and extensions can only be installed from a local directory.
Environment plugins extend what an environment does. Subcommand extensions extend what the flox command does: an executable named flox-<name> becomes flox <name>, the way git-<name> becomes git <name>. An extension is installed per user rather than per environment, and Flox never runs one on its own — it runs when you invoke it. (These are unrelated to the IDE extensions that integrate editors and coding agents with Flox.)

How dispatch works

When flox <name> doesn’t match a built-in subcommand, Flox looks for an executable flox-<name> — first in its managed extensions directory (flox-<name>/flox-<name> under $XDG_DATA_HOME/flox/extensions/, typically ~/.local/share/flox/extensions/), then on PATH — and replaces itself with it. Everything after the name is passed through verbatim, the extension inherits your environment, and Flox adds three variables: Built-in command names are reserved: dispatch never fires for them, and installing a flox-install is refused up front rather than leaving you with an extension that can never run. Global options placed before the name (flox -v hello) are dropped rather than forwarded; anything after the name belongs to the extension.

Installing, listing, and removing

flox extension install . installs the current directory. Reinstalling with --force is how an extension updates; remove deletes the install directory and any state kept inside it.

Writing an extension

An extension is a directory — conventionally named flox-<name>, which is how the name is derived when there is no manifest — containing an executable flox-<name>, written in any language. An optional flox-extension.toml at the source root names it explicitly:
name is lowercase ([a-z0-9][a-z0-9_-]*) and must match the directory when both are present; description is recorded but not yet shown by flox extension list. Only the executable and flox-extension.toml are copied on install; anything else in the source directory is left behind, so keep an extension to one self-contained executable. The canonical reference is flox-hello-local: clone it and install from the working tree, and it becomes flox hello-local.

Combining a plugin and an extension

An environment plugin runs at activation but has no command surface of its own; an extension has a command surface but no place in the activation. Take the vault-secrets plugin above: it exports secrets during flox activate, and a failed lookup surfaces only as an aborted activation. A flox-vault-secrets extension, installed once per user, gives it a command: flox vault-secrets check runs "$FLOX_BIN" list --config to read the current environment’s manifest, pulls out the [plugins.vault-secrets] table, and reports which references resolve before you activate. Invoked inside an activated shell, the extension also inherits FLOX_ENV_PROJECT and FLOX_ENV_CACHE, so it can read any state the plugin leaves under its cache directory (by convention $FLOX_ENV_CACHE/plugins/<plugin-name>/). Today that is two installs: flox install for the package, flox extension install for the command.

Beyond secrets

Secrets retrieval fits [plugins] well because “environment variable name → secret path” is exactly the kind of per-environment configuration shared logic needs. That shape isn’t unique to secrets — a plugin could equally:
  • Standardize the config for a linter or formatter across every environment that installs it, instead of copying the same [vars] or [hook] entries into each manifest.
  • Toggle a package’s optional behavior — verbose logging, a feature flag, a telemetry opt-out — per environment.
  • Inject build-time metadata, like a license key or an internal registry URL, that a package needs to configure itself correctly.
With the session-wrap hook the space widens from configuration to behavior: a sandbox plugin runs the whole session inside an isolation boundary — an ordinary installable package, with no sandbox-specific code in Flox itself. Flox doesn’t distinguish any of these from a secrets plugin. [plugins] is free-form storage plus a convention for reading it; what a given plugin does with its table — and with its hook — is entirely up to its author. And when a plugin needs a command of its own — to review state, grant access, or trigger work — a subcommand extension provides one.

Further reading