Skip to main content

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 approved status 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-holds is 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_APPROVER_TOKEN no falls back to REEFLEX_TOKEN bearer token sent on approve/reject only. Set it when your core binds approvers with REEFLEX_RESOLVER_TOKENS: that credential is accepted on the resolve route and refused 401 on the read routes, so one token cannot do both jobs. Leave it unset and nothing changes. Never logged.
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_TOKEN is the project-wide name for the reeflex-core bearer token, used by the reeflex-claude and reeflex-wordpress adapters. reeflex-holds is the one exception: it reads the same bearer token, but from REEFLEX_TOKEN instead, per its own original brief. Same credential, same purpose, just a different env var name for this one package — set REEFLEX_TOKEN here to whatever value the other adapters put in REEFLEX_CORE_TOKEN. A non-breaking unification (accepting REEFLEX_CORE_TOKEN with REEFLEX_TOKEN as a fallback) is a candidate for a future 0.1.1, not implemented here.

REEFLEX_TOKEN stopped being optional for approve/reject against a reeflex-core 0.2.0+ default deployment (RFX-84). Core now defaults to REEFLEX_REQUIRE_VERIFIED_APPROVER=true, which means it takes the approving principal from the credential, not from REEFLEX_PRINCIPAL. A resolve made with no bearer token — or with one core has no binding for — is refused 403 principal_not_verified, and this client surfaces core's own reason verbatim, 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's REEFLEX_RESOLVER_TOKENS and put it in REEFLEX_APPROVER_TOKEN. REEFLEX_PRINCIPAL must then agree with that binding: assert someone else's identity and core answers 403 principal_mismatch rather than silently substituting.

Put it in REEFLEX_APPROVER_TOKEN, not REEFLEX_TOKEN, and this is a correction (RFX-245). This paragraph used to say to put the bound credential in REEFLEX_TOKEN and that list_holds / get_hold / get_freeze_status were unaffected. Measured against core v0.2.2, they are not: core accepts a REEFLEX_RESOLVER_TOKENS credential on the resolve route and nowhere else — by design, so an approver's credential never becomes a key to submitting actions — so a read made with it answers 401 unauthorized and reeflex-holds list exits 1. Keep the shared gate token in REEFLEX_TOKEN for the reads and the bound credential in REEFLEX_APPROVER_TOKEN for the decision; with only REEFLEX_TOKEN set both routes send it, exactly as before.

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_APPROVER_TOKEN=     # only if your core binds approvers; falls back to REEFLEX_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_APPROVER_TOKEN": "",
        "REEFLEX_PRINCIPAL": "human:leo",
        "REEFLEX_VERIFY_SSL": "true"
      }
    }
  }
}

Notes:

  • command must resolve to a Python that has this package installed (pip install reeflex-holds, or pip 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) if python is not reliably on Claude Desktop's PATH.
  • Leave REEFLEX_TOKEN empty (or omit it) if your core has no REEFLEX_AUTH_TOKEN configured.
  • Set REEFLEX_VERIFY_SSL to false only 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's GET /v1/holds supports limit/cursor, but list_holds here only exposes status (per this package's brief). UPGRADE: add optional limit/cursor arguments to list_holds if a queue ever exceeds core's page size (currently 100).
  • get_freeze_status is best-effort only -- see the section above. UPGRADE: call a real freeze-status endpoint once reeflex-core ships one.
  • REEFLEX_TOKEN (this package) vs REEFLEX_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: accept REEFLEX_CORE_TOKEN with REEFLEX_TOKEN as a fallback in a future non-breaking release.
  • stdio transport only. The MCP SDK also supports sse and streamable-http; this package only wires up stdio (matching the brief and the primary Claude Desktop use case). UPGRADE: expose transport as a CLI flag or env var if a hosted/remote MCP surface is ever needed.

Metadata

Release files for reeflex-holds 0.2.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for reeflex-holds 0.2.1
File Size Uploaded
reeflex_holds-0.2.1.tar.gz 45.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for reeflex-holds 0.2.1
File Interpreter ABI Platform
reeflex_holds-0.2.1-py3-none-any.whl Python 3 none any Details

Total release size: 70.1 kB

Release files / reeflex_holds-0.2.1.tar.gz

Download URL reeflex_holds-0.2.1.tar.gz
Size 45.0 kB
Tags Source
SHA-256 checksum
How to use checksums
535c6433e30965c04b8ef08e9d9d5e1482759b1b2ebdd8bcdffe8f594f9d0976
BLAKE2b-256 checksum
How to use checksums
f3be7a8e49d5b22fccdf1f58e4c0a052b72667523f944e6b4ea15c373504b821
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 Oct 5, 2026.

Transparency log

Release files / reeflex_holds-0.2.1-py3-none-any.whl

Download URL reeflex_holds-0.2.1-py3-none-any.whl
Size 25.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
86313e1559325a118276dbf340617899b8c50b6417d10d53198b9c52c81fcbe9
BLAKE2b-256 checksum
How to use checksums
c12758edaeb27cef6f1d8b0d0a0f34fe0e13da0f1873a59f9ef3a00d96486a90
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 Oct 5, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.2.1 This release

2 release files

0.2.0

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

0.0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page