Writing a bmad-loop plugin
August 19, 2026 · View on GitHub
A plugin extends the bmad-loop orchestrator without touching its core loop. It can be as simple as a settings-only data file or as complex as the bundled Unity game-engine layer. A plugin can:
- observe, veto, or mutate the run at every lifecycle stage (a hook bus);
- contribute settings that render in the settings TUI and persist to policy;
- inject its own workflow sessions at defined points in the dev/review cycle.
Plugins are folder-drop: a directory with a plugin.toml manifest (plus any
helper scripts) dropped under .bmad-loop/plugins/<name>/. No registration, no
install step. A plugin that ships in-process Python is loaded only when you
trust it by name — dropping a folder in never runs code.
Already wrote a CLI adapter profile or the old
[engine]block? Same idea — declarative TOML + optional scripts, discovered and overlaid. The plugin system generalizes both.
Quick start: the smallest plugin
A data-only plugin carries one setting and zero behavior. Create
.bmad-loop/plugins/hello/plugin.toml:
[plugin]
name = "hello"
version = "1.0.0"
api_version = 1
description = "Smallest possible plugin."
author = "you"
[[settings]]
key = "greeting"
type = "str"
default = "hi"
label = "Greeting"
help = "Shown in the settings UI once this plugin is enabled."
That's a complete, loadable plugin. It has no [hooks] and no [python], so it
runs no code and is byte-identical to "no plugin" at run time — it exists only to
contribute a setting. This is the shape the bundled
example plugin ships in.
A setting-only section appears in the TUI only once the plugin is enabled:
# .bmad-loop/policy.toml
[plugins]
enabled = ["hello"]
Distribution & discovery
Plugins are discovered from three sources, overlaid in precedence order (a later same-name plugin overrides an earlier one; a new name extends the set):
| Source | Path | Precedence |
|---|---|---|
| Builtin | bmad_loop/data/plugins/<name>/plugin.toml | base |
| Entry point | bmad_loop.plugins group | reserved (see below) |
| Project-local | <project>/.bmad-loop/plugins/<name>/plugin.toml | highest |
Each plugin lives in its own directory — that directory is the plugin's
{scripts} root (see {scripts} substitution), so its
manifest and helper scripts sit together.
Entry points are a documented future seam. discover() already yields an
entry_point source between builtin and project, but _discover_entry_points()
returns nothing today — folder-drop is the only live distribution path. When
pip-installable plugins land, they slot in here with no change to authors or to
discovery order (importlib.metadata selectable entry points, group
bmad_loop.plugins). See src/bmad_loop/plugins/loader.py.
The manifest (plugin.toml)
Every plugin is one plugin.toml. Only [plugin] name + api_version are
required; every section below is optional, so a plugin opts into exactly what it
needs. The manifest parses into the immutable PluginManifest
(src/bmad_loop/plugins/model.py).
[plugin] — metadata
| Field | Type | Default | Purpose |
|---|---|---|---|
name | string | — | Required. Plugin id; the directory name; the [plugins.<name>] key. |
api_version | int | — | Required. Plugin-API version this manifest targets (currently 1). |
version | string | 0.0.0 | Your plugin's own version. |
description | string | "" | One line; shown in tooling. |
author | string | "" | Attribution. |
priority | int | 0 | Cross-plugin ordering at a shared stage; lower runs first, then load order. |
seed_files | list | [] | Project-relative gitignored files to copy into each isolated worktree. |
seed_globs | list | [] | Project-relative glob patterns to expand + copy into each worktree. |
seed_files / seed_globs must be project-relative (an absolute path is
rejected at load). They let a plugin prime an isolated checkout with gitignored
paths it needs — e.g. the Unity plugin seeds an MCP-generated skill tree.
[[settings]] — settings schema
Each entry contributes one setting. See Settings for the full reference.
[[settings]]
key = "strict" # required; unique within the plugin
type = "bool" # bool | int | float | str | select
default = false
label = "Strict mode" # TUI label (falls back to the key)
help = "..." # TUI help text
# select-only:
# options = ["a", "b"]
# numeric hints (int/float):
# min = 0
# max = 10
[hooks.<stage>] — declarative (out-of-process) hooks
A shell command bound to a lifecycle stage. See Hooks.
[hooks.pre_session]
cmd = 'python3 "{scripts}/probe.py"'
timeout_sec = 120 # default 120; must be >= 1
blocking = true # non-zero exit vetoes (defers) the unit
fail_closed = false # default: a hook *error* (timeout/launch) fails open
[python] — in-process module (trust-gated)
[python]
module = "hooks.py" # plugin-relative file
class = "MyPlugin" # subclass of bmad_loop.plugins.Plugin (default "Plugin")
Declaring [python] makes the whole plugin trust-gated: the module is never
imported unless the plugin is in [plugins] enabled. See Trust.
[workflows.<name>] — provided workflows
An extra agent session injected at a stage. See Workflows.
[workflows.lint-sweep]
stage = "post_dev_phase" # post_dev_phase | post_review_result | pre_commit_gate
role = "dev" # dev | review
prompt = "/lint-sweep {story_key}"
blocking = false # true: a failed session defers the unit
Declare <name>_enabled / <name>_blocking settings to let operators disable a
step or flip its gate per run — see
Making a workflow configurable.
{scripts} substitution
In any hook cmd or workflow prompt, {scripts} expands to the plugin's own
directory — so a plugin references its bundled scripts without hardcoding a path
that breaks across machines or between a builtin and a project-local copy.
Trust & safety
There are two trust tiers, by design:
-
Declarative tier (always runs). A data-only or declarative plugin — settings +
[hooks.<stage>]shell commands — loads and runs as soon as it is discovered. This is the same risk surface as the oldengine.toml *_cmdhooks or a project's verify commands: operator-authored shell, trusted by virtue of living in the repo. -
In-process tier (trust-gated). A plugin that declares a
[python]module is never imported or executed unless itsnameis in:[plugins] enabled = ["my-plugin"]Dropping a
[python]plugin folder in never runs its code. The module (and anything it provides that depends on the module — itson_<stage>handlers, itsvalidate, and its provided workflows) stays inert until you list it. An un-enabled[python]plugin is recordedplugin-untrustedin the run journal.
Failure isolation. Every hook — subprocess or Python — is wrapped. A Python
handler that raises is caught (except Exception only — RunStopped/SIGTERM as
BaseException always propagate), journalled plugin-error, and the offending
instance is disabled for the rest of the run. The run survives. A blocking
declarative hook fails open by default (an error lets the run continue; only a
clean non-zero exit vetoes); set fail_closed = true to make any failure defer
the unit. An in-process handler can opt into the same by setting fail_closed = True on its class.
Versioning. Every manifest declares api_version. The framework supports a
set of versions (SUPPORTED_API). A builtin with an unsupported version is a
hard error (a packaging bug we shipped); a third-party one is skipped with a
warning (plugin-skipped) so a stale drop-in can never take a run down.
Settings
A [[settings]] entry is presentation + validation metadata. The vocabulary
matches the core settings fields exactly:
| Field | Applies to | Meaning |
|---|---|---|
key | all | Unique within the plugin; the policy + env key. |
type | all | bool | int | float | str | select. |
default | all | Value when the operator hasn't set one. |
label | all | TUI label (falls back to key). |
help | all | TUI help text. |
options | select | Non-empty list of allowed string values. |
min/max | int/float | Numeric bounds (widget hints). |
Rendering. Once a plugin is enabled, its settings appear as their own section
in the settings TUI (generated from the schema — see
src/bmad_loop/settings_schema.py). They persist to a [plugins.<name>] table in
policy.toml:
[plugins]
enabled = ["my-plugin"]
[plugins.my-plugin]
strict = true
mode = "b"
Reading a setting.
- In an in-process plugin:
self.settings["strict"]— the manifest defaults overlaid by the operator's[plugins.<name>]table. - In a declarative hook: each setting is exported as an environment variable
BMAD_LOOP_SETTING_<KEY>(uppercased), already resolved. - Anywhere with a
Policy:policy.plugin_setting("my-plugin", "strict", default).
Settings are data, not code — a plugin can carry [plugins.<name>] settings
without being in enabled (the settings UI just won't surface a disabled plugin's
section). Only the in-process [python] module is trust-gated.
Hooks
A hook binds a stage to behavior. The hook bus
(src/bmad_loop/plugins/bus.py) fans each stage out to every bound plugin, in
registry order (priority, then load order). A run with no plugin bound to a
stage does no work for it (an O(1) fast-path) — zero-plugin runs stay
byte-identical.
Declarative hooks
A [hooks.<stage>] shell command. The bus runs it with:
-
cwd = the unit's worktree (or repo root);
-
a
BMAD_LOOP_*environment describing the run:Var Meaning BMAD_LOOP_STAGEthe stage firing BMAD_LOOP_RUN_ID/BMAD_LOOP_RUN_DIRrun identity BMAD_LOOP_REPO_ROOT/BMAD_LOOP_WORKTREEgit roots BMAD_LOOP_STORY_KEY/BMAD_LOOP_ROLE/BMAD_LOOP_PHASE/BMAD_LOOP_BRANCHunit context BMAD_LOOP_AGENTScomma-separated CLI agent ids in the worktree BMAD_LOOP_PLUGINyour plugin's name BMAD_LOOP_SETTING_<KEY>each resolved setting
A blocking hook's non-zero exit vetoes (defers) the unit. A non-blocking
hook is advisory (logged plugin-hook).
Mutating from a declarative hook. Emit a single JSON object on the last non-empty stdout line:
{
"shared": { "scanned": 42 },
"mutate": { "proposed_commit_message": "rewritten by my-plugin" },
"veto": { "action": "defer", "reason": "lint failed" }
}
shared— merged into the cross-stageshareddict.mutate— only whitelisted fields for the current stage.veto—action∈skip|defer|pause. Supplying avetoreplaces the implicit "non-zero exit = defer".
Any non-JSON output is treated as advisory log text.
In-process hooks
Subclass bmad_loop.plugins.Plugin and define on_<stage>(self, ctx) methods.
The bus calls the handler for each stage you implement; you only mark the stages
you handle, so the fast path holds for the rest.
from bmad_loop.plugins import Plugin
class MyPlugin(Plugin):
fail_closed = False # a raised handler is isolated; True also defers the unit
def on_pre_commit(self, ctx):
ctx.proposed_commit_message = f"{ctx.proposed_commit_message}\n\nShipped-by: me"
Optionally override validate(self, policy) to reject an incompatible config at
startup (raise PluginError) — e.g. a coupling between a plugin setting and a
core policy field. This runs once before any stage; a raise fails the run fast
(it is a deliberate config rejection, not isolated like a hook bug).
Don't do expensive or side-effecting work in
__init__. Construction happens at registry-build time, and a raised exception there disables the instance.
The HookContext
Every hook receives one HookContext (src/bmad_loop/plugins/context.py) for the
stage. It carries:
- Read-only facts (properties, no setter):
run_id,story_key,epic,phase,attempt,role,worktree,branch,repo_root,run_dir,agents,result_json(a copy),session_status,verify_reason,decision_action,settings. Observe these; you can never rewrite history. - A mutable whitelist — assign only these, and only where the stage allows:
proposed_prompt,proposed_env,proposed_feedback,proposed_commit_message,proposed_decision. ctx.shared— a free-form, JSON-serializable dict that persists across stages (the engine backs it withRunState.plugin_shared, so it survives pause/resume). Use it to carry state between your own hooks.
Veto is ctx.veto(action, reason), with action:
| Action | Routes onto the engine's existing… | Effect |
|---|---|---|
skip | quiet retire | unit dropped (DEFERRED), no notification |
defer | defer primitive | unit deferred + operator notified |
pause | escalation | run pauses (raises RunPaused) |
There is no new abort path — a veto maps onto control flow the engine already
has. Multiple plugins can object; the bus collects every veto without
short-circuit and resolves the most-conservative one (pause > defer >
skip), so load order can never hide a severer objection. A post_*-stage veto
is clamp-conservative only: a plugin can escalate the engine's own decision,
never silence it.
Stage reference
Stages fire in pre_/post_ pairs around each unit of work. post_* stages see
the mutations earlier pre_* stages made. The mutable surface column lists
what a hook may assign at that stage; everything else on the context is read-only
there.
Run / loop (no unit — a pause veto pauses the run)
| Stage | When |
|---|---|
pre_run / post_run | around the whole run |
pre_pick_next / post_pick_next | around selecting the next story |
pre_epic_boundary / post_epic_boundary | at an epic transition |
Story / unit
| Stage | When | Mutable surface |
|---|---|---|
pre_story / post_story | around one story | veto (pre_) |
pre_worktree_setup / post_worktree_setup | around isolated-worktree provisioning | — |
pre_ready_gate / post_ready_gate | around the engine-ready gate | veto (pre_) |
pre_worktree_teardown / post_worktree_teardown | around teardown (in a finally) | observe-only — a veto here cannot un-tear-down |
pre_rollback / post_rollback | around a failed attempt's git reset --hard (only when a rollback actually runs) | observe-only — a veto here cannot block the reset |
pre_integrate | before integrating a finished unit | — |
pre_merge / post_merge | around the local branch merge | — |
Dev
| Stage | When | Mutable surface |
|---|---|---|
pre_dev_phase / post_dev_phase | around the dev attempt loop | veto (pre_); post_dev_phase is a workflow injection point |
pre_dev_session | before each dev session | proposed_prompt, proposed_env, veto |
post_dev_verify | after dev or repair verification | — |
post_dev_verify fires on both legs of the dev phase — once after the dev
session's verification, and again after each repair session's — so a handler sees
it more than once per story, not once. The two legs share one attempt
counter bounded by [limits] max_dev_attempts, which is also the bound on how
many times the stage can fire for one story. Write handlers to be idempotent and
to key on the correlation fields below rather than on the story alone. It also
fires on the way to a pause: an attempt whose session reported a CRITICAL
escalation emits before the run stops, on either leg.
post_dev_verify exposes ctx.command_results: an immutable tuple of the
per-command CommandResult records core just executed. Each has command,
returncode, the existing merged bounded output_tail, and separate stdout
and stderr strings. Those two are intended to be the streams essentially whole
— they are not cut to [verify] stream_capture_kb, which bounds only what is
written to disk — but they are not unbounded either: a hard 32 MiB per-stream
ceiling applies, so a pathologically chatty command cannot grow the orchestrator's
peak memory with the number of configured verify commands. When that ceiling cuts
a stream the tail is what a plugin receives, and the matching journal record's
stdout_bytes / stderr_bytes still report what the command emitted, so the cut
is always detectable rather than silent. Ordinary suites never reach it. This is
observation data only: a plugin cannot change the verifier's outcome or the commit
decision. The run's journal.jsonl also records
one verify-command-result entry per command with run/story/attempt/stage and
verification-sequence correlation, output_tail, byte counts, and run-relative stdout_path /
stderr_path pointers under the run's verify/ directory; full streams are not
embedded in the journal. That store is deliberately separate from logs/, which
holds coding-CLI pane captures named after session task ids and is read as such
by the TUI.
Two more context fields say which verification the results came from, because
nothing else on the context can: both the dev leg and the repair leg emit
post_dev_verify from Phase.DEV_VERIFY, and ctx.attempt is one per-story
counter the repair leg continues rather than restarts, so its value orders the
two but names neither.
| Field | Value |
|---|---|
ctx.verification_stage | "dev" for the initial dev verification, "fix" for a repair one, None if none ran |
ctx.verification_sequence | the story's 1-based ordinal for that pass, or None if it recorded nothing |
Together they are the join key: the verify-command-result entries carrying this
story_key + verification_stage + verification_sequence are exactly this
context's results, one per record, ordered by command_index. The sequence is
monotonic per story across a pause/resume — unlike attempt, which a human
re-arm reuses — so it is safe to persist as a correlation id.
Read ctx.command_results == () together with verification_stage; on its own it
is ambiguous:
verification_stage is None— no verify pass ran. Several causes land here and the empty tuple names none of them: the session did not complete (ctx.session_status), an earlier gate already failed the attempt — the dev-artifact check or the deferral harvest (ctx.verify_reason) — or the engine variant suppressed the pass for this leg (stories mode skips it on a plan-halt leg, which has no implementation to build).- stage set,
verification_sequence is None— the pass ran and executed nothing, because[verify] commandsis empty. No journal record exists either. - stage set, sequence an int — those commands ran, and each has a matching journal record.
What lands on disk is bounded by [verify] stream_capture_kb (default 256 KiB per
stream): the tail is retained, and the record stays explicit about the cut —
stdout_bytes / stderr_bytes are what the command emitted, stdout_captured_bytes /
stderr_captured_bytes how much of that reached disk, and stdout_truncated /
stderr_truncated their inequality. Both counts are UTF-8 lengths of the decoded
stream, not file sizes: the files are written in text mode, so Windows newline
translation makes the file larger there. Set the knob to 0 to retain nothing at
all — no files are written and the pointers are null, but the record still lands
with the full byte counts, because "nothing was retained" and "the command was
silent" are different facts. Retaining is observation and never fails a run: if the
write raises (ENOSPC, a read-only run dir, or a verify/ directory whose
confinement cannot be established — the store refuses rather than write through
a symlink a session planted), the pointer is null and capture_error
carries the reason. A plugin reading these pointers must therefore treat both
None and a missing file as normal, and consult *_truncated before assuming a
file holds a command's whole output. Treat verifier output as potentially sensitive and store, upload, sign,
or act on it only from an explicitly configured plugin.
The dev phase is the whole of this surface. [verify] commands also run at
the review gate — verify_review / verify_review_stories /
verify_review_bundle end on the same core classifier — and none of those runs
are journalled or published to any hook. Five engine gates reach them: the
converged review pass, the review-budget-exhaustion rescue, the review-timeout
salvage, and both passes inside the skip-review commit path (which runs the gate
again after a repair). bmad-loop confirm --reverify runs the commands too, out
of band by construction — the run that parked the story is finished, so there is
no journal to write to and no hook bus to emit on.
Two consequences a handler has to be written for:
verify-command-resultentries are not a complete census of a run's verifier invocations. Every story that reaches a commit ran the commands at least once more than the records show. Never derive "the verifier ran N times" or "the last thing the verifier saw" from the journal — derive only "these are the dev-phase passes", which is what the records claim.- A green commit is not evidence that the last journalled pass was green, and a
red journalled pass is not evidence the commit was blocked: a
fixpass can fail and the story still commit after a later review-gate run that left no record. Correlate a decision with thedev-decision/fix-decision/review-resultentries beside the results, not with the results alone.
The boundary is deliberate, not an oversight — the review leg would need its own
hook stage rather than a second meaning for one named post_dev_verify — and is
tracked as a follow-up in #656.
Review
| Stage | When | Mutable surface |
|---|---|---|
pre_review_phase | before the review loop | veto |
pre_review_session | before each review session | proposed_prompt, proposed_env, veto |
post_review_session | after each review session | — |
post_review_result | after a review verdict | a workflow injection point |
pre_fix_session | before a verify-repair session | proposed_prompt, proposed_env, veto |
None of these carries the review gate's [verify] commands results — that gate
runs the commands and retains nothing. See the boundary note above ### Review.
Commit
| Stage | When | Mutable surface |
|---|---|---|
pre_commit_gate | just before every commit, on every path | a workflow injection point; defer-safe (the unit may still defer) |
pre_commit | before committing (after pre_commit_gate) | proposed_commit_message; only a pause veto is honored (the unit is mid-commit) |
post_commit | after committing | — |
Generic session boundary
| Stage | When | Mutable surface |
|---|---|---|
pre_session | before every session (after the role-specific pre_*_session, so it sees its mutations) | proposed_prompt, proposed_env, veto |
post_session | after every session | — |
Sweep (deferred-work cycle — bundles inherit all per-story stages)
| Stage | When |
|---|---|
pre_sweep_cycle / post_sweep_cycle | around a sweep cycle |
pre_triage_session / post_triage | around triage |
pre_migrate_session / post_migrate | around a legacy-ledger migration |
pre_close_resolved / post_close_resolved | around closing resolved entries |
pre_decision / post_decision | around a human-decision item |
pre_bundle / post_bundle | around a deferred-work bundle |
pre_materialize_bundles / post_materialize_bundles | around materializing bundles |
Workflows ([provides])
A workflow is the conservative form of custom orchestration: an extra agent session injected at a lifecycle stage, run through the engine's normal session machinery — no new pipeline stage. It is the right tool when you want an additional pass (a doc sync, a lint sweep, an extra reviewer) without rewriting the loop.
[workflows.doc-sync]
stage = "post_dev_phase" # injection point (see below)
role = "review" # which adapter runs it: dev | review
prompt = "Update CHANGELOG.md for story {story_key} if it introduced user-facing changes."
blocking = false # true: a non-completed session defers the unit
- Injection stages are deliberately limited to where the unit's worktree is
live and the dev/review work is on disk:
post_dev_phase(right after dev lands),post_review_result(after a review verdict — fires only when the orchestrator review loop actually runs;review.trigger = "recommended"skips it on stories whose dev session recommends no follow-up), andpre_commit_gate(unconditionally just before every commit — review, skip, and budget-rescue paths alike — so a session there evaluates the exact tree about to commit).pre_commit_gateis defer-safe: it fires before the unit enters COMMITTING, so a blocking workflow whose session doesn't complete still defers cleanly. Other stages lack a worktree or run after teardown. promptexpands{story_key},{run_id}, and{scripts}.- The orchestrator appends two sections to what you write, and appends them
after the session gates fire, so a
pre_workflow_session/pre_sessionprompt rewrite cannot strip them: a## Sprint boardprohibition — the same one dev and review prompts carry, because a workflow session runs while the orchestrator's ownsprint-status.yamladvance is still uncommitted and must not be "fixed" or reverted (#437) — and the## Completion signalcontract naming the marker file the session must write before ending its turn. Writepromptas a self-contained instruction; do not restate either. (Stories mode has no board, so the first section is absent there.) - The injected session is a first-class session: it fires
pre_workflow_session→pre_session→post_session, is recorded on the task, and counts toward the token budget. Its journal entries areworkflow-start/workflow-end. blocking: a blocking workflow whose session doesn't complete defers the unit (through the existing defer primitive). A non-blocking one is advisory.- A workflow from a
[python]plugin is trust-gated along with the module: it fires only when the plugin is enabled. A workflow from a pure-declarative plugin fires whenever the plugin is discovered.
registry.provided_workflows() lists declared workflow names for introspection.
Making a workflow configurable
The blocking and (in effect) on/off state a workflow declares in its manifest
are defaults. A plugin can let an operator tune them per run — disable a step
or flip its gate — by declaring settings that follow a naming convention. No
Python is required; the registry reads the resolved settings when it injects.
The convention, keyed on the workflow's name:
| Setting key | Type | Effect |
|---|---|---|
<name>_enabled | bool | When explicitly false, the step is dropped — no session runs. |
<name>_blocking | bool | Overrides the manifest's blocking flag for that workflow. |
Default semantics — absent settings change nothing. The overlay only acts on
a setting that is present and (for _enabled) explicitly false; _blocking
falls back to the manifest value when unset. A plugin that declares none of these
settings is byte-identical to one written before the feature existed.
Declare the matching [[settings]] so the keys are first-class (typed,
documented, surfaced in the settings UI) and operators can flip them from
[plugins.<plugin>] in policy.toml:
# A gate step: generated advisory by default, an operator can make it block.
[workflows.nfr]
stage = "pre_commit_gate"
role = "review"
prompt = "Run the NFR assessment for the changes in {story_key}."
blocking = false # manifest default: advisory
[[settings]]
key = "nfr_enabled" # <name>_enabled -> drop the step when false
type = "bool"
default = true
help = "Run the NFR workflow after review."
[[settings]]
key = "nfr_blocking" # <name>_blocking -> override the blocking flag
type = "bool"
default = false
help = "Escalate the unit when the NFR gate is not satisfied."
An operator then opts in from policy:
[plugins.tea]
nfr_blocking = true # flip the advisory gate to blocking
td_enabled = false # turn the test-design step off entirely
Interaction with the blocking / defer path. <name>_blocking feeds the same
WorkflowSpec.blocking the engine already honors: a blocking workflow whose
session does not complete defers the unit through the existing defer
primitive (see Workflows above). The overlay only changes
which value that flag holds at injection time — it adds no new control flow. (For
quality gating on a workflow's output rather than its completion, do that in an
in-process on_pre_commit hook; the manifest blocking flag only checks session
completion.)
Disabling every step at a stage is free. When a setting turns off the last
remaining workflow at a stage, that stage drops out of registry.workflow_stages()
too, so the engine's O(1) per-stage injection guard skips it entirely — the same
as if no workflow had ever been declared there.
Worked walkthrough: the guardrails plugin
The repo ships a complete example under
examples/plugins/guardrails/ that exercises a
setting, an observe hook, a veto gate, a commit-message mutation, and a provided
workflow — in ~40 lines of Python plus a manifest. Build it yourself:
1. Make the folder. In your project:
.bmad-loop/plugins/guardrails/
plugin.toml
guardrails.py
2. Write the manifest (plugin.toml): metadata, a [python] module, two
[[settings]] (trailer, forbid_epic), and one [workflows.doc-sync] bound to
post_dev_phase. Copy it from the example.
3. Write the module (guardrails.py):
from bmad_loop.plugins import Plugin
class GuardrailsPlugin(Plugin):
fail_closed = False
def on_pre_story(self, ctx):
# observe: count stories in the cross-stage shared dict
ctx.shared["stories_seen"] = ctx.shared.get("stories_seen", 0) + 1
def on_pre_dev_phase(self, ctx):
# gate: skip a "parked" epic
parked = int(self.settings.get("forbid_epic") or 0)
if parked and ctx.epic == parked:
ctx.veto("skip", f"epic {parked} is parked")
def on_pre_commit(self, ctx):
# mutate: append a trailer to the commit message
trailer = str(self.settings.get("trailer") or "").strip()
if trailer and trailer not in (ctx.proposed_commit_message or ""):
base = (ctx.proposed_commit_message or "").rstrip()
ctx.proposed_commit_message = f"{base}\n\n{trailer}" if base else trailer
4. Enable + configure in .bmad-loop/policy.toml:
[plugins]
enabled = ["guardrails"]
[plugins.guardrails]
trailer = "Automated-by: bmad-loop"
forbid_epic = 0 # set to an epic number to park it
5. Run. On the next bmad-loop run:
- the settings TUI shows a guardrails section with
trailer+forbid_epic; - each story increments
stories_seenin the run'splugin_shared; - a story in the parked epic is skipped before its dev session;
- after dev lands, the doc-sync workflow runs an extra review-role session;
- each commit message gets the trailer appended.
Drop [python] from the manifest and guardrails from enabled, and the plugin
goes completely inert — proof of the trust gate.
Platform portability
bmad-loop's core is portable Python, and the OS-specific work is quarantined behind
seams (the tmux transport, the ProcessHost lifecycle, the hook interpreter — see
Porting bmad-loop to a new OS). Plugin helper scripts
are spawned under the orchestrator's own interpreter (sys.executable), not a
PATH-resolved python3 — so a bundled script may import bmad_loop and reach those
seams instead of re-implementing OS primitives. Follow this discipline:
-
Use the
ProcessHostseam for pid lifecycle. For terminate / force-kill / liveness, callget_process_host()rather than guardingos.kill/signal.SIGKILL/taskkillbehind your ownsys.platformbranch:from bmad_loop.process_host import get_process_host host = get_process_host() host.terminate(pid) if host.is_alive(pid): host.force_kill(pid)The seam already carries the POSIX and Windows behavior, so the script gains a new OS for free when a host registers.
-
Stay dependency-free. Beyond
bmad_loopitself, import only the stdlib so the script runs anywhere the core does. If you genuinely need a third-party package on one platform, make it an optional extra inpyproject.tomland import it lazily with a clear error if missing — never at module top level. The bundled Unity plugin does exactly this forpsutil: imported only on non-Linux process discovery (a core dependency on Windows, thenon-linuxextra on macOS), so the dep-free Linux/WSL path never pulls it in. -
Guard the primitives that have no seam. Anything absent or differently-shaped on Windows that isn't behind a seam —
cp/--reflink, symlinks,/procscanning,/tmp,start_new_session— still needs a fallback behind asys.platformbranch, with a# portability: <reason>comment so the intent (and the CI portability guard,tests/test_portability_guard.py) stays honest. Keep the Linux fast path byte-identical; the Windows branch can be best-effort and is not exercised until a native-Windows backend ships. WSL is Linux, so it takes the fast path unchanged.
The Unity plugin's unity_teardown.py is the worked example: it delegates its
SIGTERM→SIGKILL sweep of leaked Editor / MCP-server processes to
get_process_host() (so it inherits Windows behavior), while still doing its own
worktree-bound process discovery via the guarded /proc-with-psutil-fallback
scan. See it alongside unity_setup.py / unity_cleanup.py (and the
Game Engine plugin guide) for both shapes.
Reference
- Model + base class:
src/bmad_loop/plugins/model.py - Manifest parser:
src/bmad_loop/plugins/manifest.py - Discovery + overlay + api-check:
src/bmad_loop/plugins/loader.py - Trust gate:
src/bmad_loop/plugins/trust.py - Registry (the inter-pillar contract):
src/bmad_loop/plugins/registry.py - Hook bus + dispatch:
src/bmad_loop/plugins/bus.py - Hook context + veto:
src/bmad_loop/plugins/context.py - Settings schema:
src/bmad_loop/settings_schema.py - The bundled Unity engine plugin (a real, complex example):
src/bmad_loop/data/plugins/unity/ - Game-engine specifics: Game Engine plugin guide