Metadata-Version: 2.4
Name: metamynd-client
Version: 0.9.0
Summary: MetaMynd/AgentSafe authorize-gate client for Python — the supported reference implementation of MAGP §8.3
Author: MetaMynd
License-Expression: MIT
Project-URL: Homepage, https://metamynd.ai/developers/python
Project-URL: Documentation, https://metamynd.ai/developers/spec
Project-URL: Source, https://github.com/Metamynd/agentsafe-guard
Project-URL: Issues, https://github.com/Metamynd/agentsafe-guard/issues
Keywords: metamynd,agentsafe,governance,ai-agents,magp,authorization
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Security
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: cryptography
Dynamic: license-file

# 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](https://www.npmjs.com/package/@metamynd/agentsafe-guard) to find out what
the protocol actually expects.

```bash
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](#tests)).

```python
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, and nothing checks the value unless the owner configures provenance for it. 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)

```python
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:

```python
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

```python
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.

**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](#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.**

```python
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).

```python
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`](https://www.npmjs.com/package/@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:

```python
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.

**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:

```python
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())
```

### 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:

```python
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:

```python
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:

```python
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. 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

```python
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?
```

**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.

**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](https://metamynd.ai/developers/python). Each runs offline against a
test gate, and CI runs them:
[`langgraph_agent.py`](https://metamynd.ai/examples/langgraph_agent.py),
[`openai_agents_agent.py`](https://metamynd.ai/examples/openai_agents_agent.py),
[`crewai_agent.py`](https://metamynd.ai/examples/crewai_agent.py),
[`langchain_agent.py`](https://metamynd.ai/examples/langchain_agent.py),
[`pydantic_ai_agent.py`](https://metamynd.ai/examples/pydantic_ai_agent.py), and
[`plain_python_agent.py`](https://metamynd.ai/examples/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](https://www.npmjs.com/package/@metamynd/agentsafe-guard) and are deliberately not
reimplemented here — see [the full comparison](https://metamynd.ai/developers/python).

## Tests

```bash
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.

## Links

- [Full guide](https://metamynd.ai/developers/python)
- [Protocol spec](https://metamynd.ai/developers/spec) — the signed message is §8.3
- [Source](https://github.com/Metamynd/agentsafe-guard)

MIT licensed.
