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.dscript at activation, or asession-wraphook 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
floxCLI itself: an executableflox-<name>becomesflox <name>. They are installed per user, belong to no environment, and run only when you invoke them.
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’sprofile.dscripts before running your manifest’shook.on-activate; see Activating environments for where this fits in the activation timeline. - optionally, a
session-wraphook executable underetc/flox/hooks/, which lets the plugin run the entire activation session under its control. See Lifecycle hooks.
- a script in
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 avault-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:
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.
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 aprofile.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 matchingpkg-pathmakes the connection obvious to anyone reading the manifest. For a plugin that declares asession-wraphook 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.dscripts run in filename order. Flox’s own setup scripts currently top out around0800; a0900prefix runs after them, and after any other plugin your logic depends on.
flox build too, so [build] commands can
read your plugin’s exported variables — not just interactive and
flox activate -- <cmd> sessions.
Lifecycle hooks
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
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:
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:
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.
Consent and composition
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
yoryesaccepts; 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 withflox activate allow, since a prior allow may predate the wrap declaration; an unregistered directory prompts only whileauto_activateisprompt, and a denied one never does. Accepting runsflox 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 runningflox activateyourself instead:
The hook protocol
Flox writes a JSON context file readable only by you (mode0600, in
Flox’s temporary directory) and invokes the hook with five environment
variables:
FLOX_HOOK_CTX— path to the context fileFLOX_HOOK— the hook kind,session-wrapFLOX_PLUGIN_NAME— the plugin whose hook is being invokedFLOX_BIN— the invokingfloxbinary, for hooks that need to run Flox commands themselves (an image bake withflox containerize, say)FLOX_HOOK_JQ— ajqbundled with Flox, so shell-scripted hooks can parse the context without depending on one
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 duringflox 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 startandflox services restartperform to launch a new process-compose instance is never wrapped. -
Stdio is inherited but not guaranteed to be a terminal —
flox activate -- cmd | teereaches 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/ttyor 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:
<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
+ sourceline appears in filename order — Flox’s own setup scripts first, then plugin scripts. If noprofile.dlines 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 inrunmode, which skips packageprofile.dscripts entirely. -
What data did it receive? Drop the
grepand the trace shows every command inside your script as it executes, including whatflox_plugin_dataprinted:This is the only window into that call —flox_plugin_dataexists only whileprofile.dscripts are being sourced, so you can’t run it by hand in the activated shell afterward. -
Which command failed?
profile.dscripts run underset -e, so when a plugin aborts activation, the last traced command before the failure is the one that caused it.
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: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
Environment plugins extend what an environment does. Subcommand extensions extend what theflox 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
Whenflox <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 namedflox-<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 thevault-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.
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
manifest.tomlreference —[plugins]section- Secrets management — the hand-written pattern a secrets plugin packages up
- Sandboxing — the OpenShell plugin, the first
consumer of the
session-wraphook - Activating environments — where
profile.dscripts run relative tohookandprofile - flox-hello-local — the reference subcommand extension, and the in-tree extension guides
- Composing environments — how
includemerges manifests