Skip to main content

metamynd-client

The MetaMynd/AgentSafe authorize-gate client for Python — the supported reference implementation. MAGP is language-agnostic by design: the gate verifies an Ed25519 signature over a canonical string, and nothing about that is JavaScript-specific. This is the proof, and the shortest path for a Python team that doesn't want to read the Node guard to find out what the protocol actually expects.

pip install metamynd-client

Upgrading from 0.1.0? Do. 0.1.0 signs a seven-field message; the gate requires eight (resource was added), so every request from 0.1.0 is refused with CLIENT_PROTOCOL_VERSION_UNSUPPORTED. This release signs the eight-field message and is tested against the gate's own vectors and the real guard (see Tests).

from metamynd_client import MetaMyndClient

client = MetaMyndClient.from_env()  # reads METAMYND_API / AGENT_DID / AGENT_KEY

verdict = client.authorize(
    "flight-purchase", 150,
    merchant="skyward-air",
    context={"tool": "book-flight", "riskLevel": "low"},
)

if verdict.permitted:
    ...  # run the action
elif verdict.decision == "escalate":
    # ESCALATE is a HOLD, not a denial. Wait for a human; act ONLY on may_proceed.
    state = client.wait_for_escalation(verdict.escalation_id, timeout=600)
    if state.may_proceed:
        ...
else:
    raise RuntimeError(f"refused: {verdict.reason_code}")

Send an honest riskLevel

A rule that judges risk escalates a request that carries none (or an unrecognised one), instead of allowing it: an agent that omits its risk is indistinguishable from one hiding it. guard_tool never invents a risk for you, and the "low" in the examples is a placeholder, not an assessment. A real integration states an honest one — or, better, doesn't depend on the agent: the mandate's owner can set a riskTier no claim can lower (MAGP §6.3). Whatever you send is the agent's own claim: signing the context proves who said it, not that it is true, so it can only RAISE the risk. The gate derives a floor the agent cannot lower (MAGP §6.4.3): a payment at or above 70% of the per-transaction cap is high, as is the first payment to a merchant that is not on the mandate's list, not a registered payee and never paid before, and anything at or above the owner's riskTier. A call that says low can still be held for review; verdict.risk_signals (and the riskSignals of a returned GovernanceRefusal) names the cause. The owner tunes it when provisioning (--amount-review, --risk-tier). In a map_args, pass the tool's own value through — never or "low" — so a call that does not say is sent for review.

Jurisdiction (0.6.0, MAGP §8.3.12)

verdict = client.authorize("flight-purchase", 150, merchant="skyward-air",
                           context={"riskLevel": "low"}, jurisdiction="SG")

jurisdiction (ISO 3166-1 alpha-2) is trimmed, checked to be two ASCII letters and upper-cased — anything else raises ValueError before anything is signed or sent — then sent as a top-level field and signed (the v2 message: the eight fields, MAGP-AUTH-v2, the jurisdiction). Omit it and the request is the v1 message, byte for byte as before. A jurisdiction in context is unsigned and the gate ignores it. guard_tool's map_args may return "jurisdiction" too.

  • A registered payee's country wins: a signed value that differs is refused JURISDICTION_MISMATCH.
  • JURISDICTION_REQUIRED: the mandate (or an enforced rule) needs a jurisdiction and none was signed.
  • JURISDICTION_NOT_ALLOWED: the signed value is outside the mandate's list.
  • verdict.jurisdiction_refused is true for any of the three (JURISDICTION_REASON_CODES).
  • With daemon_socket=, the daemon must be agentsafe-signer ≥ 0.18.0; an older one raises DaemonError("JURISDICTION_SIGNING_UNSUPPORTED") instead of signing the wrong message.

The context is signed too (0.7.0, on by default, MAGP §8.3.13)

Every request also carries envelopeSignature: this agent's signature over the request's envelope hash, which covers the context (sent as itinerary). The gate — and a Service running agentsafe-mcp-guard ≥ 0.17.0, agentsafe-a2a-guard ≥ 0.12.0 or agentsafe-http-gateway ≥ 0.15.0 — refuses a context altered after signing (CONTEXT_SIGNATURE_INVALID); older Services ignore the field. envelope_hash(request) is the hash, byte-for-byte the gate's (checked against the shared vectors in docs/protocol/context-signature-vectors.json). To opt out:

client = MetaMyndClient(api, agent_did, agent_key, sign_context=False)   # or METAMYND_SIGN_CONTEXT=false for from_env()

The context must be JSON (a value JSON cannot carry raises PayloadNotCanonicalizable before anything is sent). With daemon_socket=, the daemon signs it (sign-envelope, every signer version); one without it raises DaemonError("CONTEXT_SIGNING_UNSUPPORTED") — the request is never sent with its context unsigned.

Guard a tool

from metamynd_client import MetaMyndClient, guard_tool, GovernanceBlocked

client = MetaMyndClient.from_env()

def book_flight(amount, merchant):
    ...  # your real implementation

governed_book_flight = guard_tool(
    client, "flight-purchase", book_flight,
    lambda amount, merchant: {
        "amount": amount, "merchant": merchant,
        "context": {"tool": "book-flight", "riskLevel": "low"},  # state an honest risk
    },
)

try:
    governed_book_flight(amount=150, merchant="skyward-air")
except GovernanceBlocked as refused:
    print(f"refused: {refused.verdict.reason_code}")
    # refused.verdict.escalation_id, for a hold: client.wait_for_escalation(...)

map_args returns amount, currency, merchant, resource and context. Async tools are supported: hand guard_tool an async def and you get an async def back (the gate call runs in a worker thread, so it never stalls your event loop). The original signature is preserved, so a framework that builds the tool schema from it — the OpenAI Agents SDK, PydanticAI — sees exactly the unguarded function. If the awaiting task is cancelled (a timeout) while the gate is answering, and the gate permits, the hold that call created is voided rather than left reserving the budget. Async generator tools are refused when you wrap them: one authorization covers one action.

A tool that raises keeps its hold. When a permitted tool returns, guard_tool captures the hold (a call with no amount at 0, since 0.19.2: it ran and spent nothing, rather than a hold left to lapse as expired); when it raises, the hold is kept, because a tool that raised may already have acted (charged the card, then failed). A kept hold counts against the mandate's cumulative cap until it lapses with the hold TTL (15 minutes unless the issuer sets MANDATE_HOLD_TTL_MS), so a burst of failures can use up the budget for that window with nothing bought. Once you know an error means nothing happened, tell the wrapper:

  • raise ToolNotExecuted, or any exception with nothing_executed = True, and the hold is released; or
  • pass release_on_error=True, or release_on_error=lambda exc: isinstance(exc, MyValidationError) for the errors you know come before any action.

GovernanceBlocked from a service that re-verified the request is always released: nothing ran. A hold a service has claimed is that service's to settle, and the gate refuses an agent's release.

In-process and cooperative. guard_tool runs in your own process: it makes the governed path the one your agent takes, and records every decision, but code that can import book_flight (or reach the credential it uses) can still call it directly. The enforcement boundary is a separate process that holds the tool and its credentials and re-verifies the signed request — see Behind a gateway or MCP server.

Tools you hand to an agent framework: guard_agent_tool (0.9.0)

Wrap every tool you give a framework with guard_agent_tool; keep guard_tool for code that calls a tool itself.

agent.tool_plain(guard_agent_tool(client, "flight-purchase", book_flight, map_args))   # PydanticAI
ToolNode([guard_agent_tool(client, "purchase-order", raise_po, map_args)])              # LangGraph

guard_agent_tool is guard_tool(..., on_refusal="return"): a refusal comes back as the call's result (a GovernanceRefusal the model reads) instead of an exception. Why it matters: when a model asks for several tools in one turn, PydanticAI and LangGraph's default ToolNode run the sync ones in worker threads. A refusal that raises aborts the turn — while the sibling calls already in their threads keep going: authorized, run, and their results delivered to nobody, so a retried turn can do them twice. A returned refusal is just one call's result: the turn completes and the model sees every outcome.

guard_tool keeps raising by default on purpose: code that calls a guarded tool directly should get an exception for a refusal — a returned value could be mistaken for the tool's result.

Other ways to the same end:

  • Use async def tools. A cancelled async guarded call is cancelled, and a hold it was already granted is released.
  • guard_tool(..., on_refusal="return") (what guard_agent_tool does). A refusal is returned as a GovernanceRefusal — a JSON-ready dict (refused, action, decision, reasonCode, escalationId, message; the full verdict on .verdict) — so it is just that call's result, the turn completes, and the model sees every outcome. on_refusal=callable gets the GovernanceBlocked and returns whatever the tool should return instead. An async def handler needs an async def tool; with a sync tool it raises TypeError when wrapping (0.8.1). Only governance refusals are returned: an unreachable gate still raises. Check with isinstance(result, GovernanceRefusal), not truthiness.
  • LangGraph: ToolNode(tools, handle_tool_errors=GovernanceBlocked) turns the exception into a ToolMessage (its default re-raises everything except its own invocation errors).
book = guard_tool(client, "flight-purchase", book_flight, map_args, on_refusal="return")
result = book(amount=150, merchant="skyward-air")
if isinstance(result, GovernanceRefusal):
    ...  # the tool did not run; result["reasonCode"] says why

An escalation can also end as modified — a reviewer changed the action instead of approving it as asked. That stops the wait too, is never may_proceed, and status.next_escalation_id is the follow-up hold for the modified action.

Behind a gateway or MCP server

A service protected by @metamynd/agentsafe-http-gateway or an MCP server using agentsafe-mcp-guard doesn't take the agent's word: it re-verifies the signed request against the agent's own policy. The client hands it over:

import requests
from metamynd_client import governance_headers, guard_tool

def book_via_gateway(amount, merchant):
    # inside a guarded tool, governance_headers() is the x-magp-request header for THIS call
    return requests.post("https://gateway.example/book",
                         json={"amount": amount, "merchant": merchant},
                         headers=governance_headers()).json()

book = guard_tool(client, "flight-purchase", book_via_gateway, map_args)

Outside a guarded tool: verdict.signed.headers(). It is not a parameter of your function, so the tool's signature is unchanged. Without the header the service refuses (MISSING_GOVERNANCE) — the safe failure. The gateway also refuses arguments that differ from what was signed (PAYLOAD_NOT_BOUND), so the amount you authorized is the amount that executes.

The hold (since 0.24.0). A gateway that runs a value-bearing call claims its hold before it executes it, and settles it itself. If a tool that called governance_headers() returns with the hold still unclaimed, the gateway ran nothing (it refused), so guard_tool releases the hold instead of capturing it. Before 0.24.0, returning the gateway's refusal (as .json() above does on a 403) booked it as spend. Raising ToolNotExecuted on a refusal is still the clearer code. A call with no amount is settled at 0 as before.

After a human approves a held action, the request you first signed is stale (a service refuses one more than a few minutes old). Sign a fresh one carrying the approved authorization:

signed = client.sign_request("flight-purchase", 150, merchant="skyward-air",
                             context={"riskLevel": "low"},
                             authorization_id=state.authorization_id)
requests.post(url, json=body, headers=signed.headers())

A gateway too old to run an approval (0.20.0). A gateway older than agentsafe-mcp-guard 0.27.0 / agentsafe-http-gateway 0.26.0 / agentsafe-a2a-guard 0.18.0 cannot claim a person-approved hold (the gate refuses it AUTHORIZATION_CONTEXT_REQUIRED; it runs nothing). When a guarded tool's exception carries that code, resume() keeps the approval's hold instead of voiding it, sets upgrade_required, hold_kept and detail (which gateway to upgrade) on the exception, and warns once. The gate re-opens the approval's one resume, so resume() runs it after the upgrade — no second approval. An approval whose hold was voided is refused AUTHORIZATION_VOIDED (the owner must approve again), not AUTHORIZATION_ALREADY_USED.

Bind the WHOLE payload, not just eight fields (0.2.0 / 0.3.0, MAGP §8.3.9, §8.3.11)

The signature covers the agent, action, amount, currency, merchant and resource — not a payee or an account number. Pass payload= and the client also signs a digest of the complete payload (RFC 8785 canonical JSON), bound to that one authorization; the service that executes it is held to exactly that payload, and a claim with a different one is refused:

verdict = client.authorize("wire", 250, merchant="acme", context={"riskLevel": "low"},
                           payload={"payee": {"iban": "NL91ABNA0417164300"}, "reference": "INV-1042"})
# or from guard_tool: have map_args return "payload"

Pass the payload exactly as the service will receive it as JSON. A payload JSON cannot carry (NaN, bytes, a datetime) raises PayloadNotCanonicalizable before anything is sent. The gate echoes the digest it stored, and authorize refuses a permit that does not (PAYLOAD_BINDING_NOT_CONFIRMED, releasing the hold) — so a proxy that strips the fields, or a gate that predates the feature, is a loud failure rather than a silently unbound request.

0.4.0 — a genuine JSON null payload can now be bound. payload= used to default to None, so there was no way to say "bind the literal value null" — it meant "no payload" either way. The default is now the sentinel NO_PAYLOAD (also what omitting "payload" from map_args's return means); payload=None — explicitly, or map_args returning {"payload": None} — now binds null. Existing code that never passes payload=None explicitly is unaffected.

After a reviewer MODIFIES a held action, the hold it mints carries no digest (the action you signed is not the one that was approved). Bind the payload of the action that WILL run, then hand the service a fresh signed request:

bound = client.bind_payload(status.authorization_id, "wire", payload)     # 0.3.0
if not bound.bound:
    raise RuntimeError(f"payload not bound: {bound.reason_code}")
signed = client.sign_request("wire", amount, payload=payload, authorization_id=status.authorization_id)

The gate applies it only while the hold is live, unclaimed and unbound, and never overwrites a digest.

Remote signer daemon — the key never enters this process (0.5.0)

agentsafe-signer (integrations/agentsafe-signer, Node) is a separate local process that holds the agent's key and signs on request, over a Unix domain socket (a Windows named pipe there). Point the client at it instead of a key:

client = MetaMyndClient("https://metamynd.ai/api/v1", agent_did, daemon_socket="/path/to/signer.sock")
# or from_env(): set AGENT_DAEMON_SOCKET instead of AGENT_KEY

Every signature this client makes — the authorize request, a payload binding — is asked of the daemon instead of computed with a key held in this process; nothing else about the client changes; sign_request, authorize, guard_tool, bind_payload all work exactly as before. Same protocol the Node guards' own daemon key providers speak (agentsafe-guard's key-providers.mjs): the daemon reconstructs the exact canonical message itself from structured fields, so this client never hands it — or is asked to hand it — bytes to blind-sign. DaemonError (.code) reports DAEMON_UNREACHABLE, PAYLOAD_BINDING_UNSUPPORTED (the daemon predates sign-payload, signer < 0.15.0 — upgrade it, or don't bind a payload against it) and the daemon's own refusal codes. Since 0.24.1 the daemon also signs the resume claim (signer ≥ 0.23.0, sign-resume-claim), so a resumed approval is taken once at the gate as it is with an in-process key; an older daemon resumes unclaimed with a warning, and a daemon that cannot sign refuses the resume SIGNER_UNREACHABLE. Stdlib only: socket.AF_UNIX on Linux/macOS, raw ctypes calls into kernel32 for the named pipe on Windows — no extra dependency either way. Verified against the real agentsafe-signer daemon (tests/test_daemon_signer.py), on both transports (CI runs the POSIX path; Windows was run and confirmed directly, not merely reasoned about — the named-pipe transport has genuine platform-specific failure modes a mock would not have caught).

Settle, release, look up

client.capture(verdict.authorization_id, 150, booking_ref="PNR1")   # commit at the FULL amount
client.void(verdict.authorization_id, reason="not needed")           # release an UNCLAIMED hold
out = client.outcome(verdict.authorization_id)                       # did it happen? may I retry?

From agent.metamynd.json, the ids of a permitted call, and LangGraph error status (0.23.0).

client = MetaMyndClient.from_config("agent.metamynd.json")             # passphrase="..." for an encrypted key
book = guard_agent_tool(client, "flight-purchase", book_flight, map_args,
                        on_verdict=lambda v: log.info("%s %s %s", v.decision, v.authorization_id, v.event_id))
# inside the running tool: current_verdict().authorization_id
ToolNode([guard_langchain_tool(client, "flight-purchase", book_flight, map_args)])  # refusal = status="error"

from_config reads what the Node guard's createGuardFromConfig reads (apiBase, agentDid, agentKey, a daemon, or agentKeyEncrypted with passphrase). Keyword overrides win over the file: point it at a staging or local gate with api="http://localhost:9926/api/v1" (or its alias api_base=, 0.23.1). A leading UTF-8 byte-order mark in the file is ignored (0.23.1). on_verdict sees every gate answer, a permit included; a callback that raises is a warning, never a different outcome. guard_langchain_tool is guard_agent_tool as a LangChain tool whose refusal is a ToolMessage with status="error"; the turn still completes.

Who settles. The service that executed the action settles or releases the hold it claimed; an agent normally doesn't. Once a service has claimed a hold, only that service can settle it below the authorized amount or release it — the gate refuses the agent's attempt (ok=False, with the reason), on purpose: otherwise an agent could wait for a purchase to happen and then take its budget back. An agent can capture at the full amount, and void a hold nobody has claimed.

No stalls on a flaky IPv6 path (0.22.0). The gate answers on IPv6 and IPv4 addresses. The client races them (Happy Eyeballs, RFC 8305) instead of trying each in turn with the full timeout, so a network that drops some IPv6 connections no longer turns a 0.5 s call into 15 or 30 s. The client never retries an authorize: one whose answer never arrives raises GateUnreachable, the tool does not run, and the hold the gate may have made is released in the background.

Why a settlement was refused (0.5.3). A refused capture or void is ok=False with reason_code — the gate's stable code (MAGP 8.7.8) — and detail, a sentence for people. Branch on reason_code, never on detail: COUNTERPARTY_MISMATCH (403: the hold is claimed and you are not its claimer), NOT_HELD (409 on capture: it is already settled or released — usually an earlier call landed, so read outcome() before retrying; on void it is not an error), AUTHORIZATION_EXPIRED, AMOUNT_EXCEEDS_AUTHORIZED, AUTHORIZATION_NOT_FOUND (404), and the rest of the table in the spec. The code is read from data.reasonCode first, so it is right on the 200 NOT_HELD void too, whose message is Not voided (NOT_HELD).

Settling below the authorization (0.5.2). A service that charged less passes pay_to, the account it paid (a Hedera account id or an EVM address): client.capture(auth_id, 120, pay_to="0.0.5005"). If the owner lists that merchant's accounts (MAGP 8.7.14), a lowered capture without a listed pay_to is refused (PAYEE_NOT_REGISTERED), and the settlement observer only counts a credit to the account named.

What was authorized, what was settled, and how far to trust it. Outcome reports authorized_amount (kept after settlement — None means unknown, not zero), settled_amount, and settlement_evidence: unattested (the full authorization, counted for a caller that is not the claiming service), unclaimed_lowered (released below the authorization before anyone claimed it), counterparty_attested (the claiming service said so), independently_confirmed (an independent observer of the payment saw this amount — in practice the Hedera mirror node, for an x402-bound hold), operator_resolved, or reconciled_at_authorized. On an x402-bound hold, a service settling below the authorization must back the lower figure with the transaction id and the account paid (pay_to), and the mirror node must show that account credited with that amount; the x402 facilitator probe keeps no settlement record, so it can never confirm a figure on its own. Anything the observer cannot confirm refuses the lower figure rather than believing it (the service can still settle in full). A lowered settlement on a hold that is not x402-bound is not probed and is recorded as counterparty_attested.

Retry only when it is safe. Outcome carries two flags: nothing_executed (nothing has run so far) and retry_safe (nothing can run later either — only expired and not_executed). Retry a purchase only on retry_safe. A not_started hold is not yet retry-safe (a service queued behind a slow gateway can still claim it — void it first); unknown and in_flight are ambiguous and are reconciled, never retried blindly.

Proving a decision happened (0.5.1). verdict.event_id is the id the anchored evidence event for THIS decision was recorded under — what GET /magp/evidence/{event_id}/proof (§10.3) needs to serve its Merkle inclusion proof. It is a different id from verdict.authorization_id (the mandate hold); that id does not work there. None only when the gate's own best-effort evidence write failed.

Examples

Six runnable examples, each a single file you can download from metamynd.ai/developers/python. Each one governs a real call, so it needs an agent to act as: create a free sandbox agent first (npx create-metamynd-agent --sandbox, no account needed) and export its DID and key as the example's header describes. Run without them, an example exits with an error. (This repository's CI runs them against a local test gate.) The examples: langgraph_agent.py, openai_agents_agent.py, crewai_agent.py, langchain_agent.py, pydantic_ai_agent.py, and plain_python_agent.py (no framework: the whole lifecycle — authorize, hold, gateway handoff, capture, void, outcome — in one file). Frameworks are imported lazily, so an example still runs the governance without its framework installed.

Scope

This is the entry price, not a full SDK: it signs and submits authorize requests, returns the verdict, wraps a tool (guard_tool, sync or async), hands the signed request to a gateway, follows up an escalate, and settles or looks up a hold. Local bundle evaluation (deciding at the edge, no network) and evidence inclusion-proof fetch exist in the protocol and in the Node guard and are deliberately not reimplemented here — see the full comparison.

Tests

python -m metamynd_client --selftest        # offline: the details that cost a day each,
                                            # plus the whole client against an in-process gate stub

The repository's CI additionally checks the client against the shared protocol vectors (docs/protocol/authorize-vectors.json, generated from the gate's own message builder and read by both the gate's tests and this client's — so the two cannot drift apart again), runs every example, has the real agentsafe-mcp-guard and agentsafe-http-gateway verify what the client signs, and drives a real agentsafe-signer daemon over its actual socket/pipe transport for the daemon-backed signer.

MIT licensed.

Metadata

Release files for metamynd-client 0.24.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 metamynd-client 0.24.1
File Size Uploaded
metamynd_client-0.24.1.tar.gz 116.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for metamynd-client 0.24.1
File Interpreter ABI Platform
metamynd_client-0.24.1-py3-none-any.whl Python 3 none any Details

Total release size: 181.0 kB

Release files / metamynd_client-0.24.1.tar.gz

Download URL metamynd_client-0.24.1.tar.gz
Size 116.1 kB
Tags Source
SHA-256 checksum
How to use checksums
bdfce2acd5bb48cc1b8b79be22d1ae731e210450f2f2962427650d67678e146b
BLAKE2b-256 checksum
How to use checksums
d3fe501e3705aaedc427426ba528d9014346941442fff8c3f530c5ee4ab0e7f3
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Release files / metamynd_client-0.24.1-py3-none-any.whl

Download URL metamynd_client-0.24.1-py3-none-any.whl
Size 64.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
3ab08ea5aca6710bece6fed41045895750aac202d9af1a80c25b0addbb25d446
BLAKE2b-256 checksum
How to use checksums
107c1c9982353174fbe5d4378fe289b22cba4e179896474ee153506adee3733c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Release history Release notifications | RSS feed

This release

0.24.1 This release

2 release files

0.24.0

2 release files

0.23.1

2 release files

0.23.0

2 release files

0.9.0

2 release files

0.8.1

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.2

2 release files

0.5.1

2 release files

0.2.0

2 release files

0.1.0

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