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. And a plugin isn’t limited to running a script at activation — through lifecycle hooks, a plugin can participate in the whole life of an environment: wrapping the session, injecting variables into every attaching shell, running a daemon for the activation’s lifetime, or cleaning up at teardown. Sandboxing is the flagship consumer of those hooks. See Beyond secrets for other examples.
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; lifecycle hooks additionally require "1.16.0" and a feature flag, and are currently prototype-only.

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. See Activating environments for where this fits in the activation timeline.
    • optionally, executables and scripts under etc/flox/hooks/, the hook tree, which let a plugin participate in other phases of the environment’s lifecycle.
A plugin’s profile.d script reads its own configuration with the flox_plugin_data shell function, which Flox provides during activation. Hook executables receive the same table through a context file instead, since they run 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. Each extension point below is a place where a plugin can participate. The profile.d convention covers the most common need (setup at activation start); the rest are lifecycle hooks. Extension points for the remaining phases (init, lock, push/pull, containerize, services) are named in the design but deliberately not built until something needs 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. Plugins that use lifecycle hooks need 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 plugins that declare lifecycle hooks the alignment is mandatory: the [plugin-hooks] declaration, the 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, [plugin-hooks] declarations are ignored with a warning and the activation proceeds normally — so an environment that declares hooks stays usable for teammates who haven’t opted in.
profile.d scripts cover one moment in the lifecycle: environment setup at activation start. Lifecycle hooks let a plugin participate everywhere else. Each hook kind 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.

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 hook files after the plugin (<plugin-name>, or 1000_<plugin-name>.sh for sourced scripts) is a requirement, not tidiness.

Declaring hooks: [plugin-hooks]

Executable hooks don’t run just because a package ships them. The environment’s manifest must opt in, through a typed, top-level section:
Each value names a plugin — the install ID of a package that must ship the matching hook file. At activation, Flox verifies the binding in both directions:
  • A declared plugin that isn’t installed, that doesn’t ship the declared hook, whose hook file isn’t executable, or whose hook file is actually shipped by a different package (shadowing a plugin’s name) is an activation error.
  • A shipped hook that isn’t declared is ignored with a warning naming the fix: Ignored session-wrap hook '<name>' shipped by an installed package. Declare it under [plugin-hooks] in the manifest to enable it.
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 three specific powers a profile.d script doesn’t have:
  • session capture — a session-wrap hook execs your terminal session under code the plugin controls;
  • per-attach injection — an env hook writes into every shell’s environment, for the activation’s lifetime;
  • supervised lifetime — a sidecar hook gets a daemon that Flox keeps alive alongside the activation.
profile.d and on-deactivate.d scripts have none of those powers, so they stay undeclared. 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. 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 message 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 hooks, restate the declaration in your own manifest.
  • Auto-activation asks first. Entering a directory whose environment declares a session wrapper prompts before handing over the session, and the default is No:
    Bare Enter declines; declining is remembered for the rest of the shell session (cleared when you leave the directory). The prompt appears on every entry, even for directories you’ve allowed with flox activate allow — a prior allow may predate the wrap declaration — and accepting starts a foreground session rather than the usual in-place activation. On fish and tcsh, or without a terminal, no prompt is shown — a notice points at running flox activate yourself instead.

The hook protocol

Executable hooks share one invocation contract. Flox writes a JSON context file readable only by you (mode 0600 for session-wrap and env hooks; the sidecar’s context lives inside its private 0700 runtime directory) and invokes the hook with:
  • FLOX_HOOK_CTX — path to the context file
  • FLOX_HOOK — the hook kind: session-wrap, env, or sidecar
  • FLOX_PLUGIN_NAME — the plugin whose hook is being invoked
  • FLOX_HOOK_JQ — a guaranteed jq, so shell-scripted hooks can parse the context without depending on one
  • FLOX_BIN — the invoking flox binary (session-wrap only)
The context is versioned (ctx_version) and its fields vary by hook kind, but every hook receives plugin_table — its own [plugins.<name>] table as verbatim JSON. This is how hook executables read their configuration: they run outside the activation shell, so the flox_plugin_data function isn’t available to them. Hooks are language-agnostic — a hook with real logic can be a compiled binary shipped in the package; simple ones stay shell. Shell hooks run with the invoking user’s environment before any activation setup, which on macOS can mean bash 3.2 — keep them compatible.

session-wrap

The marquee hook: it runs the entire activation session under the plugin’s control. Flox dispatches it during flox activate, after the environment is locked, built, and rendered, immediately before the session would start. The hook composes whatever boundary it implements — an OS sandbox, a container, a remote hand-off — and execs the activation inside it; on success it never returns. A hook that returns instead fails the activation: an environment that declares a wrapper either activates wrapped or not at all. The context gives a wrapper two ways to re-enter the activation: inner_argv, a host-side argv sufficient for same-filesystem boundaries that re-exec flox under a wrapper process, and invocation_type, the structured form of how the user invoked activation (interactive, -c shell string, or -- cmd argv), from which container and remote boundaries compose their 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 marks the wrapped process with a scope value from its context (_FLOX_SESSION_WRAPPED); re-activating the same environment inside its own boundary skips the wrap, while activating a different wrapping environment inside it is an error.
  • In-place activation is refused. eval "$(flox activate)" cannot hand your current shell to a wrapper.
  • Stdio is inherited but not guaranteed to be a terminal — a hook that wants to prompt must check the tty state the context provides and talk to the terminal directly, never stdout.

env

An executable that contributes environment variables — at activation start and again at every shell attach, which is the seam profile.d scripts don’t reach (attaching shells replay the recorded activation environment rather than re-running setup). The hook prints a JSON object of variables on stdout:
Contract essentials:
  • Runs on every attach, so it must be fast and idempotent — check before appending to path-like variables.
  • Multiple declared env hooks run in lexical plugin-name order, last-writer-wins; their contributions are reapplied to each shell and win over values set by profile.d scripts or the user’s hooks.
  • Fail-closed: a non-zero exit or malformed output fails the activation or attach. This is a declared control surface, not best-effort decoration.
  • _FLOX_-prefixed variables are rejected — Flox’s own control state can’t be forged through this channel.

sidecar

A long-running process with the activation’s lifetime, supervised by the same Flox process that supervises services. The generic form of “my plugin needs a daemon”: a policy broker, a proxy, a watcher. Supervision contract:
  • Spawned at activation start with the hook context plus a private runtime directory (mode 0700, for sockets) beside the services socket. Spawn failure fails the activation.
  • A crash mid-activation is logged and non-fatal; there is no automatic restart. Design plugins to fail closed on a dead sidecar.
  • At teardown the sidecar is terminated (SIGTERM, a grace period, then SIGKILL) and its runtime directory removed — after services shut down, before on-deactivate.d scripts run.
  • Its stdio is detached; a sidecar that needs to log writes its own files, conventionally under the plugin’s cache directory.

on-deactivate.d

The package counterpart of the manifest’s hook.on-deactivate: shell scripts at etc/flox/hooks/on-deactivate.d/*.sh, sourced in filename order when the last activation of the environment ends, before the user’s own hook.on-deactivate. They run with the activation-end environment replayed, flox_plugin_data available, output going to the activation’s log, and failures swallowed — teardown always continues. Like hook.on-deactivate, these scripts don’t run when the environment is torn down uncleanly (a killed supervisor, a removed state directory, containers), so they suit janitorial cleanup — caches, scratch state — not anything correctness depends on.

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, the environment was already active somewhere and this activation attached instead of re-running setup — exit the other activation first. If only Flox’s own 0100 script appears, 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.
Lifecycle hooks have a different debugging surface, since they run outside the traced activation script:
  • Verbose mode logs each dispatch (exec'ing session-wrap hook, running env hook) with the resolved hook path.
  • A session-wrap hook inherits your terminal, so anything it writes to stderr reaches you directly.
  • An env hook’s stderr is not currently captured anywhere — have the hook write diagnostics to a file while developing it.
  • Sidecar lifecycle events (spawned, exited, terminated) and on-deactivate.d script output land in the activation’s log directory.

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 declarations are dropped, as described in Consent and composition.

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 lifecycle hooks the space widens from configuration to behavior: sandbox plugins wrap the whole session under an isolation boundary, inject policy into every shell, and run enforcement daemons — all as ordinary installable packages, 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 hooks — is entirely up to its author.

Further reading