reeflex-holds
Lists, approves, and rejects Reeflex's Human-in-the-Loop
(HIL) governance holds two ways: as real terminal subcommands
(reeflex-holds list|approve|reject, no wp-admin/dashboard/MCP client
needed -- see "CLI" below), and as an MCP server that turns the same
holds queue into a socket any MCP client can talk to (Claude Desktop, a
coding agent, a custom bot) -- without a bespoke integration per client.
What it is: a thin wrapper -- CLI and MCP tools alike -- around three
reeflex-core HTTP endpoints (GET /v1/holds, GET /v1/holds/{id},
POST /v1/holds/{id}/resolve), plus a best-effort reachability probe.
What it is NOT: it does not decide, enforce, or execute anything. Every
governance rule -- who may resolve which hold, whether the resolving identity
is allowed to act, whether a hold has expired -- is enforced by reeflex-core
(OPA/Rego + classical logic), exactly as it is for every other adapter. This
package forwards HTTP calls and relays reeflex-core's response, success or
error, verbatim. A rejection from core (409, 403, 404, ...) is never retried,
softened, or overridden here -- it surfaces as a real MCP tool error, or on
the CLI as a non-zero exit with the reason printed to stderr.
Tools
| Tool | Arguments | Calls |
|---|---|---|
list_holds |
status? (pending|approved|rejected|expired|consumed|all) |
GET /v1/holds?status= |
get_hold |
id |
GET /v1/holds/{id} |
resolve_hold |
id, decision (approve|reject), reason? |
POST /v1/holds/{id}/resolve |
get_freeze_status |
(none) | GET /healthz (best-effort; see below) |
resolve_hold never accepts a principal argument. The resolving identity
always comes from this server's own REEFLEX_PRINCIPAL configuration -- an
MCP client cannot resolve a hold "as" an arbitrary identity by simply asking
to. reeflex-core still independently enforces the operator's resolution
policy (which principal types may resolve which rule), the R3/systemic
immunity guard (irreversible_systemic_prod holds can never be resolved by
anyone), and the actor-is-approver check (the agent whose action raised the
hold can never resolve it, on any surface, including this one). None of that
enforcement lives in this package -- it lives in reeflex-core/app/server.py.
Honest two-step reality for WordPress (and other adapter) holds
Resolving a hold via MCP marks it approved IN REEFLEX-CORE ONLY. It does not execute anything. The underlying action still has to run on the adapter that raised it:
- For the WordPress adapter: the hold's
approvedstatus in core does not, by itself, delete the 50 posts (or whatever the held action was). The WordPress action completes WordPress-side, either via the wp-admin "run approved" button, or automatically the next time the adapter resubmits the same envelope (e.g. on the admin's next matching request) and core sees a matching, approved, unconsumed hold. - The same is true for any other adapter:
reeflex-holdsis a read/resolve console for the hold record, not an execution engine. It has no way to reach into WordPress, a CI pipeline, or anything else and run the action.
If you approve a hold here and the "thing" doesn't visibly happen, that is expected -- check the originating adapter for its own resubmission / "run approved" mechanism.
Config (env)
| Variable | Required | Default | Purpose |
|---|---|---|---|
REEFLEX_CORE_URL |
no | http://127.0.0.1:8080 |
reeflex-core base URL |
REEFLEX_TOKEN |
no | unset | optional bearer token; adds Authorization: Bearer <token> to every request. Never logged. Holds the same reeflex-core bearer token the other adapters call REEFLEX_CORE_TOKEN — see the naming note below. |
REEFLEX_PRINCIPAL |
only for resolve_hold |
unset | "type:id" of the resolving identity, e.g. human:leo or agent:triage-bot. Split on the first colon (an id may itself contain colons). list_holds, get_hold, and get_freeze_status do not need it. |
REEFLEX_VERIFY_SSL |
no | true (full TLS verification) |
set to 0/false/no/off (case-insensitive) to disable TLS certificate verification -- dev/self-signed endpoints only, at the operator's own risk. Same env name and semantics as reeflex-claude and the WordPress adapter, per the project's standing TLS-verify-opt-out rule. |
REEFLEX_HOLDS_TIMEOUT |
no | 10 (seconds) |
hard socket timeout for every HTTP request to core; this package never issues an unbounded request |
Naming note.
REEFLEX_CORE_TOKENis the project-wide name for thereeflex-corebearer token, used by thereeflex-claudeandreeflex-wordpressadapters.reeflex-holdsis the one exception: it reads the same bearer token, but fromREEFLEX_TOKENinstead, per its own original brief. Same credential, same purpose, just a different env var name for this one package — setREEFLEX_TOKENhere to whatever value the other adapters put inREEFLEX_CORE_TOKEN. A non-breaking unification (acceptingREEFLEX_CORE_TOKENwithREEFLEX_TOKENas a fallback) is a candidate for a future 0.1.1, not implemented here.
REEFLEX_TOKENstopped being optional forapprove/rejectagainst a reeflex-core 0.2.0+ default deployment (RFX-84). Core now defaults toREEFLEX_REQUIRE_VERIFIED_APPROVER=true, which means it takes the approving principal from the credential, not fromREEFLEX_PRINCIPAL. A resolve made with no bearer token — or with one core has no binding for — is refused403 principal_not_verified, and this client surfaces core's ownreasonverbatim, which names the principal and the two settings that would change the answer. To resolve holds against such a core, ask its operator for the bearer token bound to your principal in core'sREEFLEX_RESOLVER_TOKENSand put it inREEFLEX_TOKEN.REEFLEX_PRINCIPALmust then agree with that binding: assert someone else's identity and core answers403 principal_mismatchrather than silently substituting.list_holds/get_hold/get_freeze_statusare unaffected.
Why the mcp SDK
This package's only dependency is the official
MCP Python SDK (mcp.server.mcpserver.MCPServer,
mcp>=2 -- the post-2026-07-28-spec successor to the older FastMCP).
reeflex-core itself stays dependency-free by contract (stdlib + OPA
subprocess only), and the other two adapters (reeflex-claude,
reeflex-wordpress) are also zero/near-zero-dependency by design -- but this
package's entire job is to speak the MCP protocol correctly to arbitrary MCP
clients, and hand-rolling that protocol (JSON-RPC framing, capability
negotiation, tool schema generation, notification handling, the streaming and
stdio transport edge cases) is exactly the kind of thing a widely-used
official SDK exists to get right once. Depending on mcp here is the
proportional choice for a thin, single-purpose MCP surface -- this is not a
change to reeflex-core's or the other adapters' dependency posture.
Install
pip install reeflex-holds
reeflex-holds is published on PyPI (requires Python 3.10+) — check
the release history for
the current version. To work from a local checkout instead (for development,
or to track main):
git clone https://github.com/Reeflex-io/reeflex.git
cd reeflex/reeflex-holds
pip install -e .
CLI: list, approve, reject -- from a terminal, no MCP client needed
reeflex-holds (the same console script, called with a real subcommand) is
a human-typable console for the holds queue -- no wp-admin, no dashboard, no
hand-written HTTP call:
export REEFLEX_CORE_URL=http://127.0.0.1:8080
export REEFLEX_TOKEN= # only if your core requires a bearer token
export REEFLEX_PRINCIPAL=human:leo # required for approve/reject, not for list
reeflex-holds list --status pending
reeflex-holds approve <hold-id> --reason "reviewed the envelope, looks fine"
reeflex-holds reject <hold-id> --reason "not today"
list prints a table (id, status, rule, ability, magnitude, timestamps, plus
the decider and how that decider was established once a hold has been
resolved); add --json on any subcommand for raw JSON instead. Exit codes:
0 on success, 1 if reeflex-core refused the request (e.g.
actor_is_approver, not_resolvable, a 404), 2 on a local
setup/connection problem (core unreachable, or REEFLEX_PRINCIPAL unset for
approve/reject) -- never a silent 0 with no output.
These exit codes are a property of the CLI, which only exists in releases
from 0.2.0 on. An earlier wheel has no CLI at all: any argv fell into the
stdio MCP transport, so reeflex-holds approve <hold-id> exited 0 with no
output and never opened a connection (RFX-42/RFX-149). Check
reeflex-holds --help prints a usage banner before trusting an exit code
from this tool.
approve/reject never take a --principal flag: the resolving identity is
always REEFLEX_PRINCIPAL, exactly like the resolve_hold MCP tool below,
so a resolution made from this CLI is indistinguishable in reeflex-core's
evidence from one made through an MCP client. It is not automatically
indistinguishable from a dashboard resolution: since RFX-84, core records
decided_by_verified / principal_source on every resolution, and an
approver it cannot tie to a bound credential is recorded as asserted --
an unverified claim, whichever surface made it. approve/reject print
which of the two you got on stderr, and list prints it per resolved hold.
To make a CLI resolution verified, bind the operator's token in core's
REEFLEX_RESOLVER_TOKENS; core refuses a principal that disagrees with its
binding (403 principal_mismatch) rather than recording the claim.
Run reeflex-holds --help for the full usage.
Running it as an MCP server
export REEFLEX_CORE_URL=http://127.0.0.1:8080
export REEFLEX_PRINCIPAL=human:leo
python -m reeflex_holds
Called with no arguments, reeflex-holds (or python -m reeflex_holds)
starts the stdio MCP server and blocks, waiting for a client to speak the
protocol on stdin/stdout -- unchanged from before the CLI above existed.
You normally do not run it manually -- an MCP client (below) launches it as
a subprocess. Called with any argument, it runs the CLI above instead and
never touches stdio.
Claude Desktop demo
Add this to Claude Desktop's claude_desktop_config.json (Settings ->
Developer -> Edit Config), using the absolute path to your checkout:
{
"mcpServers": {
"reeflex-holds": {
"command": "python",
"args": ["-m", "reeflex_holds"],
"env": {
"REEFLEX_CORE_URL": "http://127.0.0.1:8080",
"REEFLEX_TOKEN": "",
"REEFLEX_PRINCIPAL": "human:leo",
"REEFLEX_VERIFY_SSL": "true"
}
}
}
}
Notes:
commandmust resolve to a Python that has this package installed (pip install reeflex-holds, orpip install -e .from a local checkout, as above) -- use an absolute interpreter path (e.g."C:\\path\\to\\venv\\Scripts\\python.exe"or/path/to/venv/bin/python) ifpythonis not reliably on Claude Desktop'sPATH.- Leave
REEFLEX_TOKENempty (or omit it) if your core has noREEFLEX_AUTH_TOKENconfigured. - Set
REEFLEX_VERIFY_SSLtofalseonly against a dev/self-signed core endpoint (e.g. a staging deployment) -- never in production. - Restart Claude Desktop after editing the config.
Expected transcript
Assume reeflex-core is running locally with a pending hold already in its
queue (e.g. a WordPress adapter submitted a 50-post bulk-delete that scored
require_approval under rule reeflex.policy/irreversible_broad_prod).
You type: "List pending Reeflex holds"
What happens: Claude recognizes this maps to the list_holds tool,
calls it with {"status": "pending"}, and gets back something like:
{
"items": [
{
"id": "279ac798cf8f40eb85b5ebbdecafec70",
"status": "pending",
"rule_id": "reeflex.policy/irreversible_broad_prod",
"created_ts": "2026-07-04T19:53:26Z",
"expires_ts": "2026-07-04T23:53:26Z",
"envelope": {
"action": {"namespace": "wordpress", "verb": "delete", "ability": "wordpress/delete-post"},
"axes": {"reversibility": "irreversible", "blast_radius": "broad", "externality": "internal"},
"magnitude": {"count": 50}
}
}
],
"count": 1
}
Claude responds (paraphrasing the JSON, e.g.): "There is 1 pending hold:
a WordPress bulk-delete of 50 posts (irreversible, broad, in production),
created a few minutes ago, expiring in about 4 hours. Hold id
279ac798.... Would you like to approve or reject it?"
You type: "Approve hold 279ac798cf8f40eb85b5ebbdecafec70"
What happens: Claude calls resolve_hold with
{"id": "279ac798cf8f40eb85b5ebbdecafec70", "decision": "approve"}. The
tool never asks for or sends a principal -- it resolves as whatever
REEFLEX_PRINCIPAL was configured in the server's env (human:leo above).
The response:
{
"id": "279ac798cf8f40eb85b5ebbdecafec70",
"status": "approved",
"decided_by": "human:leo",
"decided_ts": "2026-07-04T19:58:40Z",
"reason": null
}
Claude responds: "Hold 279ac798... is now approved (by human:leo).
Note: this marks it approved in Reeflex core only -- the actual WordPress
bulk-delete still needs the adapter to resubmit or run it (e.g. the
wp-admin 'run approved' button), it does not happen automatically from this
approval alone."
If the agent that raised the hold and REEFLEX_PRINCIPAL are the same
identity, or the hold has already been resolved/expired, or the resolution
policy does not allow this principal type for this rule, resolve_hold
comes back as a genuine MCP tool error carrying core's exact reason (e.g.
actor_is_approver, not_resolvable, principal_type_not_allowed) --
Claude will surface that error text, not a fabricated success.
get_freeze_status -- an honest limitation
reeflex-core has no dedicated freeze-status endpoint. The operator
kill-switch (REEFLEX_FREEZE) is an environment variable read fresh on every
/v1/decide call inside core (see reeflex-core/app/decide.py), and it is
never exposed via the HTTP API. Per this package's brief, we do not
invent a core endpoint to answer this question.
get_freeze_status therefore does the only honest thing available from
outside core: a GET /healthz reachability probe (the one universally
unauthenticated, side-effect-free endpoint core exposes). It always returns:
{
"core_reachable": true,
"freeze_state": "unknown",
"note": "reeflex-core has no dedicated freeze-status endpoint; REEFLEX_FREEZE is an operator-side environment variable re-read on every /v1/decide call ... This is a best-effort GET /healthz reachability probe only -- it cannot report the actual REEFLEX_FREEZE value. To infer freeze state: ask the operator directly, or watch for repeated 'reeflex.policy/frozen' denials in /v1/decide responses or the audit log."
}
freeze_state is always "unknown" -- this tool cannot and does not claim
otherwise. Upgrade path: if/when reeflex-core ships a real freeze-status
endpoint, this function should call it directly and drop the /healthz
fallback.
Running the tests
cd reeflex-holds
pip install -e .
python -m unittest discover -s tests -v
test_config.py and most of test_client.py / test_server.py need no
network (pure parsing, or a local stub HTTP server standing in for
reeflex-core on an ephemeral port). Every test that talks HTTP applies a
hard timeout -- this suite cannot hang.
Live smoke (owned by a separate task, T7): the tests above mock
reeflex-core. A full live smoke test -- a real local reeflex-core
instance (with OPA configured), a genuine bulk-delete envelope raising a
real hold, and a real MCP client (mcp.client.stdio.stdio_client +
ClientSession) driving python -m reeflex_holds as a subprocess against
it -- was run manually during implementation to validate the end-to-end
wiring (see the implementer's report for the transcript). That full
live-smoke harness is expected to live in reeflex-verify or an equivalent
T7 conformance step, not in this package's unit test suite.
Limits / upgrade paths
- No pagination exposed.
reeflex-core'sGET /v1/holdssupportslimit/cursor, butlist_holdshere only exposesstatus(per this package's brief). UPGRADE: add optionallimit/cursorarguments tolist_holdsif a queue ever exceeds core's page size (currently 100). get_freeze_statusis best-effort only -- see the section above. UPGRADE: call a real freeze-status endpoint oncereeflex-coreships one.REEFLEX_TOKEN(this package) vsREEFLEX_CORE_TOKEN(reeflex-claude,reeflex-wordpress) -- same bearer token, different env var name for this package only; see the naming note in Config above. UPGRADE: acceptREEFLEX_CORE_TOKENwithREEFLEX_TOKENas a fallback in a future non-breaking release.- stdio transport only. The MCP SDK also supports
sseandstreamable-http; this package only wires upstdio(matching the brief and the primary Claude Desktop use case). UPGRADE: exposetransportas a CLI flag or env var if a hosted/remote MCP surface is ever needed.
Metadata
Release files for reeflex-holds 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| reeflex_holds-0.2.0.tar.gz | 37.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| reeflex_holds-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 61.5 kB
Release files / reeflex_holds-0.2.0.tar.gz
| Download URL | reeflex_holds-0.2.0.tar.gz |
|---|---|
| Size | 37.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4943a8cd92f4b51bd4ee437050d84ab42bf3128d0b58736893b575f548c4c373
|
|
BLAKE2b-256 checksum How to use checksums |
27ff2220b4061e5a8267bcb74343b9b8a4fbc030a5b3592c6f43b539c04c2c83
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 7, 2026.
Transparency logRelease files / reeflex_holds-0.2.0-py3-none-any.whl
| Download URL | reeflex_holds-0.2.0-py3-none-any.whl |
|---|---|
| Size | 23.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
78cd0f43da7eb55779b87d0543716d47e6e4364ece2f7ffa96caaa4e55c0a14c
|
|
BLAKE2b-256 checksum How to use checksums |
7b853ee345fba8b7d5ef88d298d220479d7f6afc00d7799996f68bf963dbed7e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 7, 2026.
Transparency log