Govern a Python agent
MAGP verifies an Ed25519 signature over a canonical string. Nothing about that is JavaScript-specific, and this is the proof — pip install metamynd-client, the supported Python reference client, one file of protocol against one dependency.
The entry price, not an SDK
It reaches the identical five verdicts the Node guard does, including refusing a request signed for $50 and sent for $5,000. It is deliberately not a reimplementation of the guard — what it leaves out is listed below, plainly, so you can decide before you start rather than after.
It does
- Signs and submits authorize requests against the live gate
- Wraps a callable so it runs only on a permit — guard_agent_tool() for tools you give an agent framework (a refusal is the call's result), guard_tool() for code that calls a tool itself (a refusal raises); sync or async (in-process and cooperative: the separate gateway is the boundary)
- Returns the verdict: allow, observe, block, escalate — with the reason code
- Fails closed when there is no gate. guard_agent_tool returns a refusal with reason code GATE_UNREACHABLE, and guard_tool raises GovernanceBlocked with the same code. client.authorize() raises GateUnreachable, a RuntimeError whose .reason_code is GATE_UNREACHABLE. In every case the tool never runs, and the call is safe to retry once the gate is back.
- Waits on an ESCALATE through to its resolution — wait_for_escalation()
- Tells you when the gate, not the agent, raised the risk. The gate derives a floor the agent cannot lower: a payment at or above 70% of the per-transaction cap, the first payment to a merchant that is not on the mandate's list, and the owner's riskTier. A call that says "low" can still be held, and verdict.risk_signals names the cause (0.15.0). The owner tunes it with --amount-review and --risk-tier.
- Hands the signed request to a gateway or MCP server that re-verifies it — verdict.signed.headers()
- Settles or releases a hold and says what became of it — capture(), void(), outcome() with a retry_safe flag
- Settles the hold of every wrapped call for you (0.12.0). A tool that ran is captured. One refused downstream, or that raised ToolNotExecuted, is released. Any other failure keeps its hold, because the tool may have acted. An escalated call resumes with governed.resume(escalation_id, *same_args) once its owner approves it, and runs exactly once. Arguments that are not the request the owner approved (another amount, merchant or payload, or since 0.18.0 another context, its riskLevel included) are refused ESCALATION_REQUEST_MISMATCH before the tool runs (0.14.0).
- A self-test that proves your signing is correct before you call us — and CI that checks it against the gate's own vectors and the real guard
It does not
- Local bundle evaluation (decide at the edge, no network)
- Evidence inclusion-proof fetch
Both exist in the protocol and in the Node guard. They are buildable from the spec in Python — the canonical format is published — but that is your afternoon, not ours yet.
pip install and run the self-test
The self-test runs offline and checks the three signing details below. Run it before you call the gate: it turns the one error you would otherwise spend a day on into a pass or a fail on your own machine.
# Install from PyPI.
pip install metamynd-client
# Offline first: this checks the three signing details below without
# calling us at all, so a failure here is your environment, not the gate.
python -m metamynd_client --selftest
# Then live, against a real agent.
export METAMYND_API=https://metamynd.ai/api/v1
export AGENT_DID=did:hedera:testnet:...
export AGENT_KEY=302e020100... # exactly as issued
python -m metamynd_clientUse it as a library
Your agent framework does not matter here. Whatever picks the tool — LangGraph, the OpenAI Agents SDK, CrewAI, a while loop — the governed step is the same: ask the gate, and act only on a permit.
from metamynd_client import MetaMyndClient
client = MetaMyndClient.from_env()
verdict = client.authorize(
"flight-purchase", 150,
merchant="skyward-air",
# State an honest riskLevel: a call with none is escalated, not
# allowed. "low" here is a placeholder, not an assessment, and it
# can only raise the risk: the gate derives a floor (70% of the cap,
# a new merchant, the owner's tier) the agent cannot lower.
context={"tool": "book-flight", "riskLevel": "low"},
)
if verdict.decision == "escalate":
# ESCALATE is a HOLD, not a denial — the one verdict a naive
# integration mishandles, usually by treating it as failure.
state = client.wait_for_escalation(verdict.escalation_id, timeout=600)
# Act only on state.may_proceed. Never self-approve, and never
# treat "pending" as a yes.
elif not verdict.permitted:
raise RuntimeError(f"refused: {verdict.reason_code}")
# Only past here has anything been authorised. Call your tool now.
# Behind a gateway? verdict.signed.headers() is what it re-verifies.LangGraph, and any framework like it
Governance is not a node in your graph. A step the model chooses is a step the model can be talked out of choosing — which is the exact property that makes a prompt useless as a control. It wraps the tool, so the only path to the real function that the agent is given goes through the gate. The graph itself is unchanged. guard_agent_tool runs in your own process and is cooperative — code that can import the raw function can still call it; the enforcement boundary is a separate gateway process that holds the tool and re-verifies the signed request.
from langgraph.prebuilt import ToolNode
from metamynd_client import MetaMyndClient, guard_agent_tool
client = MetaMyndClient.from_env()
# Your tool, unchanged and unaware it is governed.
def book_flight(airline: str, amount: float, route: str) -> dict:
"""Book a flight and charge the corporate card."""
...
# The governed one. Give THIS to your ToolNode. A refusal comes back as
# the call's result (a GovernanceRefusal the model reads), never a crash.
governed_book_flight = guard_agent_tool(
client, "flight-purchase", book_flight,
lambda airline, amount, route: {"amount": amount, "merchant": airline,
"context": {"riskLevel": "high" if amount >= 1000 else "low"}}, # derived from the call, never hard-coded
)
# An ordinary LangGraph graph. Nothing about the wiring changes.
tools = ToolNode([governed_book_flight])Note what the example leaves ungoverned — a read-only vendor search — because an unwrapped tool is an ungoverned tool, and deciding which of yours are consequential is a design step nothing will do for you.
Wrap framework tools with guard_agent_tool. Every snippet on this page uses it for the tool it hands the framework: a refusal comes back as that call’s result (a GovernanceRefusal the model reads) instead of an exception. That matters when the model asks for several tools in one turn: LangGraph’s ToolNode (and PydanticAI) run the sync ones in worker threads, and a refusal that raises aborts the turn while the sibling calls already in their threads keep going — authorized and run, with results nobody receives. Keep guard_tool, which raises, for code that calls a tool itself: there an exception is the safe answer, because a returned value could be mistaken for a result.
The OpenAI Agents SDK
Same shape, one detail that is specific to this SDK: guard on the inside, decorate on the outside. function_tool builds the tool's JSON schema from your function's signature, so the guard has to be invisible to it — guard_tool uses functools.wraps for exactly that reason.
from agents import Agent, function_tool
from metamynd_client import MetaMyndClient, guard_agent_tool
client = MetaMyndClient.from_env()
# Your tool, unchanged and unaware it is governed.
def book_flight(airline: str, amount: float, route: str) -> dict:
"""Book a flight and charge the corporate card."""
...
# Guard on the INSIDE, decorate on the OUTSIDE. A refusal is the call's
# result (a GovernanceRefusal with a message the model can explain).
governed_book_flight = guard_agent_tool(
client, "flight-purchase", book_flight,
lambda airline, amount, route: {"amount": amount, "merchant": airline,
"context": {"riskLevel": "high" if amount >= 1000 else "low"}}, # derived from the call, never hard-coded
)
agent = Agent(
name="Travel assistant",
instructions="You book flights for the team.",
tools=[function_tool(governed_book_flight)],
)Reverse that order and the SDK rejects the tool with additionalProperties should not be set for object types — this could be because you’re using an older version of Pydantic, which sends you to your dependency versions, where the problem is not. You will also reach for input_guardrails: those inspect the model’s text and are useful, but they are not this. A guardrail checks what the model said. The gate decides an action — an amount, a merchant, a scope, signed by the agent’s key against a mandate its owner issued.
LangChain's create_agent
LangChain 1.x removed AgentExecutor and create_tool_calling_agent from langchain.agents entirely — create_agent (built on LangGraph internally) is the only agent constructor exported today. Same shape as every other framework here: guard on the inside, tool() on the outside, and the gate runs regardless of which tool the model's loop decides to call.
from langchain.agents import create_agent
from langchain_core.tools import tool
from metamynd_client import MetaMyndClient, guard_agent_tool
client = MetaMyndClient.from_env()
# Your tool, unchanged and unaware it is governed.
def book_flight(airline: str, amount: float, route: str) -> dict:
"""Book a flight and charge the corporate card."""
...
# Guard on the INSIDE, wrap with tool() on the OUTSIDE.
governed_book_flight = guard_agent_tool(
client, "flight-purchase", book_flight,
lambda airline, amount, route: {"amount": amount, "merchant": airline,
"context": {"riskLevel": "high" if amount >= 1000 else "low"}}, # derived from the call, never hard-coded
)
# An ordinary create_agent graph — LangChain 1.x's only agent constructor
# now that AgentExecutor is gone. Nothing about the wiring changes.
agent = create_agent(model="openai:gpt-4o-mini", tools=[
tool(governed_book_flight),
])You will also reach for middleware — LangChain ships a HumanInTheLoopMiddleware for pausing before a tool call. Useful, and not this: middleware runs inside your own process on your own say-so, with no signature and no owner-issued mandate behind it. The gate decides an action signed by the agent’s own key, verified independently of whatever LangChain code is running the request.
CrewAI
CrewAI's own pitch is multiple agents cooperating on one goal — a researcher and a booker, say. Governance still sits at the tool boundary, not in the crew's process: guard on the inside, tool() on the outside, same as everywhere else on this page.
from crewai import Agent
from crewai.tools import tool
from metamynd_client import MetaMyndClient, guard_agent_tool
client = MetaMyndClient.from_env()
# Your tool, unchanged and unaware it is governed.
def book_flight(airline, amount, route):
"""Book a flight and charge the corporate card."""
...
# Guard on the INSIDE, decorate with tool() on the OUTSIDE.
governed_book_flight = guard_agent_tool(
client, "flight-purchase", book_flight,
lambda airline, amount, route: {"amount": amount, "merchant": airline,
"context": {"riskLevel": "high" if amount >= 1000 else "low"}}, # derived from the call, never hard-coded
)
booker = Agent(
role="Flight booker",
goal="Book the requested flight within policy",
backstory="You book flights for the team.",
tools=[tool(governed_book_flight)],
)You will also reach for a Task’s guardrail — useful, and not this. A guardrail can inspect what a task’s OUTPUT says it did; it has no visibility into the signed action, the mandate that authorised it, or the amount actually charged. An unlisted merchant or a self-granted permission increase refuses at the gate with no rule written against either case, because the mandate simply never granted them — nothing in a task’s output would have looked wrong.
PydanticAI
PydanticAI is async-first and builds each tool's JSON schema from its signature and type hints. guard_agent_tool keeps both: hand it an async def and you get an async def back with the same signature, and the gate call runs in a worker thread so it never stalls the event loop.
from pydantic_ai import Agent
from metamynd_client import MetaMyndClient, guard_agent_tool
client = MetaMyndClient.from_env()
# Your tool, unchanged. async def is fine: the guard keeps it async and
# keeps its signature, so PydanticAI builds exactly the same schema.
async def book_flight(airline: str, amount: float, route: str) -> dict:
"""Book a flight and charge the corporate card."""
...
governed_book_flight = guard_agent_tool(
client, "flight-purchase", book_flight,
lambda airline, amount, route: {"amount": amount, "merchant": airline,
"context": {"riskLevel": "high" if amount >= 1000 else "low"}}, # derived from the call, never hard-coded
)
agent = Agent("openai:gpt-4o-mini",
instructions="You book flights. If a booking is refused, explain why.")
agent.tool_plain(governed_book_flight)PydanticAI runs a turn’s sync tool calls in worker threads, side by side. That is why the tool you register is a guard_agent_tool: a refusal is that call’s result, so the other calls in the turn finish and the model reads all of them.
Three details, one error message
Each of these costs a Python integration about a day, because all three fail identically and silently as SIGNATURE_INVALID. The client handles all three; they are written down here so that when you write your own, you do not rediscover them.
The key is DER PKCS#8, not a raw seed
Provisioning hands you a hex string that is DER-encoded PKCS#8, not a 32-byte seed, and the spec did not used to say so. `load_key` accepts either.
Python stringifies numbers differently
JavaScript's String(150.0) is "150"; Python's str(150.0) is "150.0". Different message, different signature — and it is not only the trailing `.0`: Python writes 0.00005 as "5e-05" where JavaScript writes "0.00005", so a sub-cent amount fails the same way. `js_number_to_string` applies JavaScript's own number-to-text rules, and is checked against Node's `String()`.
issuedAt must be byte-identical in both places
The timestamp is signed as the literal string that goes on the wire. The format itself does not matter — isoformat() verifies as happily as "…Z" — but calling the clock twice does not. With microseconds that fails every time; at second precision it fails only when a second ticks between the two calls, which is an intermittent failure under load. `authorize` formats once.
All three are in the spec at §8.3.3–§8.3.5, and the gate returns a hint on a SIGNATURE_INVALID verdict naming them as the usual causes.
Govern your own agents
Issue a mandate, bind your rules, and get an audit trail your auditor can check without asking you for anything.
