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_tool(), sync or async
- Returns the verdict: allow, observe, block, escalate — with the reason code
- Waits on an ESCALATE through to its resolution — wait_for_escalation()
- 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
- 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.
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 goes through the gate. The graph itself is unchanged.
from metamynd_client import MetaMyndClient, guard_tool, GovernanceBlocked
client = MetaMyndClient.from_env()
# Your tool, unchanged and unaware it is governed.
def raise_purchase_order(vendor, amount, items):
...
# The governed one. Give THIS to your ToolNode.
governed_raise_po = guard_tool(
client, "purchase-order", raise_purchase_order,
lambda vendor, amount, items: {"amount": amount, "merchant": vendor,
"context": {"riskLevel": "low"}}, # a placeholder: state an honest risk
)
# An ordinary LangGraph node. Nothing about the graph changes.
def call_tool(state):
try:
po = governed_raise_po(vendor="acme-supplies", amount=7_500, items="20x monitor")
return {**state, "result": f"raised {po['po']}"}
except GovernanceBlocked as refused:
# A refusal belongs back in the conversation, not swallowed.
return {**state, "result": f"refused - {refused.verdict.reason_code}"}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.
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_tool, GovernanceBlocked
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.
governed_book_flight = guard_tool(
client, "flight-purchase", book_flight,
lambda airline, amount, route: {"amount": amount, "merchant": airline,
"context": {"riskLevel": "low"}}, # a placeholder: state an honest risk
)
def refusal_for_the_model(_ctx, error):
# A refusal is an outcome the model should explain, not a crash.
if isinstance(error, GovernanceBlocked):
return f"Refused: {error.verdict.reason_code}. The flight was not booked."
return f"The tool failed: {error}"
agent = Agent(
name="Travel assistant",
instructions="You book flights for the team.",
tools=[function_tool(governed_book_flight,
failure_error_function=refusal_for_the_model)],
)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_tool, GovernanceBlocked
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_tool(
client, "flight-purchase", book_flight,
lambda airline, amount, route: {"amount": amount, "merchant": airline,
"context": {"riskLevel": "low"}}, # a placeholder: state an honest risk
)
# 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_tool, GovernanceBlocked
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_tool(
client, "flight-purchase", book_flight,
lambda airline, amount, route: {"amount": amount, "merchant": airline,
"context": {"riskLevel": "low"}}, # a placeholder: state an honest risk
)
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.
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.
