MAQPNADocs

Handle decisions in code

React to allowed, denied, held-for-approval, budget-exceeded and revoked calls in Python, TypeScript, Go and raw HTTP, with the error envelope, retry guidance and approval waits.

Every governed call ends in a decision, and a denied call is MAQPNA working, not a failure. A good agent reads the decision, changes its plan when a call is denied, waits or tells the user when a call is held for approval, and stops cleanly when it is revoked. This page shows how to tell the outcomes apart, which ones to retry, and how to wait for a human from code.

The outcomes of one call#

stateDiagram-v2
    [*] --> Sent
    Sent --> Allowed: policy allow
    Sent --> Denied: rule, default deny, DLP,<br/>pin, taint, rate limit
    Sent --> Held: require_approval
    Sent --> BudgetExceeded: budget spent
    Sent --> Revoked: kill switch
    Sent --> Unauthorized: bad token or scope
    Held --> Pending: async mode (-32002)
    Pending --> Pending: poll with approval ID
    Held --> Allowed: approved
    Pending --> Allowed: approved
    Held --> Denied: approval_denied / approval_expired
    Pending --> Denied: approval_denied / approval_expired
    Allowed --> [*]: result
    Denied --> [*]: -32001
    BudgetExceeded --> [*]: -32001 budget_exceeded
    Revoked --> [*]: -32001 revoked
    Unauthorized --> [*]: -32003

The error envelope#

On the MCP and A2A routes a refused call is a JSON-RPC error with HTTP 200 (HTTP 401 for a missing or bad token). error.data always has the same fields:

{
  "jsonrpc": "2.0",
  "id": 4,
  "error": {
    "code": -32002,
    "message": "approval required (destructive or high-impact operation); retry with header X-Maqpna-Approval-Id once approved",
    "data": {
      "approvalId": "apr_ef5f478a257784b043c4cf8a",
      "detail": "destructive or high-impact operation",
      "domain": "maqpna.com",
      "expiresAt": "2026-10-18T17:36:42.11943-04:00",
      "policy": "default-agent-policy",
      "reason": "approval_required",
      "rule": "destructive-needs-human"
    }
  }
}
Field Use it for
code The class: -32001 denied, -32002 held (async mode only), -32003 unauthorized.
data.reason What to do. Match on the text before the first : (budget_exceeded:agent/day is budget_exceeded). Never match on message.
data.policy, data.rule What to show a human, or log. Revocations show revocation/<id>; DLP shows policy dlp.
data.detail The rule's reason text, or who denied an approval.
data.approvalId, data.expiresAt Only with -32002.

The model route uses OpenAI-style errors with the same reason (see Call models). The complete list of codes and reasons is in Error and reason codes.

Step 1: Catch the decision#

from maqpna import (MaqpnaClient, PolicyDenied, ApprovalPending,
                    Unauthorized, TransportError, Result)

client = MaqpnaClient.from_env()
echo = client.tools("echo")
try:
    result = echo.call("delete_resource", id="db-1", namespace="kube-system")
except PolicyDenied as e:
    kind = (e.reason or "").split(":", 1)[0]
    if kind == "revoked":
        raise SystemExit("session revoked: stopping")
    print(f"denied by {e.data.get('policy')}/{e.rule}: {e.data.get('detail')} ({e.reason})")
except ApprovalPending as p:          # only in async mode
    print("waiting for approval", p.approval_id)
except Unauthorized:
    raise SystemExit("token rejected: run under maqpna dev run or check the token mount")
import { MaqpnaClient, PolicyDenied, ApprovalPending, Unauthorized } from "@kosa8/maqpna";

const echo = MaqpnaClient.fromEnv().tools("echo");
try {
  await echo.call("delete_resource", { id: "db-1", namespace: "kube-system" });
} catch (e) {
  if (e instanceof PolicyDenied) {
    const kind = (e.reason ?? "").split(":")[0];
    if (kind === "revoked") process.exit(1);
    console.log(`denied: ${e.rule} (${e.reason})`, e.data);
  } else if (e instanceof ApprovalPending) {
    console.log("waiting for approval", e.approvalId);
  } else if (e instanceof Unauthorized) {
    throw e;
  } else throw e;
}
_, err := client.CallTool(ctx, "echo", "delete_resource",
    map[string]any{"id": "db-1", "namespace": "kube-system"})
var denied *maqpna.PolicyDenied
var pending *maqpna.ApprovalPending
var unauth *maqpna.Unauthorized
switch {
case errors.As(err, &denied):
    kind, _, _ := strings.Cut(denied.Reason, ":")
    if kind == "revoked" {
        return fmt.Errorf("session revoked: %w", err)
    }
    log.Printf("denied by %s/%s: %s", denied.Policy, denied.Rule, denied.Reason)
case errors.As(err, &pending):
    log.Printf("waiting for approval %s (expires %s)", pending.ApprovalID, pending.ExpiresAt)
case errors.As(err, &unauth):
    return err
case err != nil:
    return err // transport or other errors
}
OUT=$(curl -sS "$MAQPNA_TOOL_ECHO_URL" -H "Authorization: Bearer $MAQPNA_TOKEN" \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"delete_resource","arguments":{"id":"db-1","namespace":"kube-system"}}}')
echo "$OUT" | jq -r '.error.code // "ok", .error.data.reason // empty'
-32001
policy_denied

When a model drives the agent, hand the denial back to the model as the tool's output (for example "denied by policy: <detail>") so it can choose another step. The framework adapters do this by default (see Framework adapters).

Step 2: Decide whether to retry#

Reason (before :) Retry? What to do
policy_denied, default_action, no_applicable_policy No Change the plan, or ask for a policy change. The same call gets the same answer.
dlp Not with the same arguments Remove the sensitive value (for example dlp:aws_access_key).
tool_definition_changed, tool_unpinned No Stop and tell a human: the tool is not the one that was reviewed.
approval_denied No A person said no. Do not queue the same call again.
approval_expired, approval_invalid Once Queue a new approval (an approval covers exactly one identical call).
rate_limited Yes, with backoff On the model route, honour Retry-After.
budget_exceeded Not before the window resets Finish with Failed and a summary; the qualifier names the scope and window (agent/day).
revoked No Stop at once. Further calls are denied; reporting the result is denied too.
token_expired, unauthorized (-32003) The SDK re-reads the token and retries once If it persists, exit. Locally, start a new maqpna dev run.
upstream_unavailable, audit_unavailable, TransportError Yes, with backoff Retry idempotent calls only, for example 1 s, 2 s, 4 s, at most 5 attempts.

The SDKs do not retry tool calls for you, except the single retry after a 401 and three retries of report_result on HTTP 503. A retried tool call is a new governed call with its own decision and audit record. Retry only tools that are safe to run twice.

Step 3: Wait for a human approval#

A rule with require_approval holds the call for an approver. There are two modes.

Sync (default). The gateway keeps the HTTP request open until an approver decides or the approval expires (approvalTimeoutSeconds, default 300 seconds), then returns the result or -32001 with approval_denied or approval_expired. All three SDKs time out a request after 120 seconds by default, so raise the client timeout (timeout=, timeoutMs, WithTimeout) or use async mode.

Async. Send the call with X-Maqpna-Async: 1. The gateway answers at once with -32002 and an approvalId. Re-send the identical call with X-Maqpna-Approval-Id until it is decided. The SDK helpers do this loop:

from maqpna import MaqpnaClient, PolicyDenied, ApprovalTimeout, wait_for_approval

echo = MaqpnaClient.from_env().tools("echo")
try:
    result = wait_for_approval(
        echo, "delete_resource", {"id": "db-1", "namespace": "dev"},
        poll_interval=2, timeout=300,
        on_pending=lambda p: print("approve", p.approval_id, "before", p.data.get("expiresAt"), flush=True),
    )
    print(result.text)
except PolicyDenied as e:
    print("not approved:", e.reason)           # approval_denied or approval_expired
except ApprovalTimeout as t:
    print("gave up after", t.waited_seconds, "s on", t.approval_id)
import { waitForApproval, PolicyDenied, ApprovalTimeout } from "@kosa8/maqpna";

const res = await waitForApproval(echo, "delete_resource", { id: "db-1", namespace: "dev" }, {
  pollIntervalMs: 2000,
  timeoutMs: 300_000,
  onPending: (p) => console.log("approve", p.approvalId),
});
res, err := client.CallToolAndWait(ctx, "echo", "delete_resource",
    map[string]any{"id": "db-1", "namespace": "dev"},
    maqpna.WithPollInterval(2*time.Second), maqpna.WithWaitTimeout(5*time.Minute),
    maqpna.WithOnPending(func(p *maqpna.ApprovalPending) { log.Printf("approve %s", p.ApprovalID) }))
BODY='{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"delete_resource","arguments":{"id":"db-1","namespace":"dev"}}}'
H=(-H "Authorization: Bearer $MAQPNA_TOKEN" -H 'Content-Type: application/json' -H 'X-Maqpna-Async: 1')
ID=$(curl -sS "$MAQPNA_TOOL_ECHO_URL" "${H[@]}" -d "$BODY" | jq -r .error.data.approvalId)
until OUT=$(curl -sS "$MAQPNA_TOOL_ECHO_URL" "${H[@]}" -H "X-Maqpna-Approval-Id: $ID" -d "$BODY"); \
      [ "$(echo "$OUT" | jq -r '.error.code // 0')" != "-32002" ]; do sleep 2; done
echo "$OUT"

Approve it from another terminal (as someone other than the user the agent acts for), MAQPNA Desk or the console:

eval "$(maqpna dev env)"
maqpna approvals list
maqpna approvals approve apr_31bc56b1ef96a592333824db --approver reviewer@localhost --note "test resource"

What the retries return, from a real local run:

# before the decision
{"jsonrpc":"2.0","id":4,"error":{"code":-32002,"message":"approval still pending; retry later with header X-Maqpna-Approval-Id","data":{"approvalId":"apr_31bc56b1ef96a592333824db",...,"reason":"approval_required","rule":"destructive-needs-human"}}}
# after approval
{"jsonrpc":"2.0","id":4,"result":{"content":[{"text":"deleted resource db-1 in namespace dev (simulated)","type":"text"}],"isError":false}}
# the same approval used again
{"jsonrpc":"2.0","id":4,"error":{"code":-32001,"message":"denied by policy default-agent-policy rule destructive-needs-human: approval_invalid: approval: already used",...}}

Set MAQPNA_ASYNC_APPROVALS=1 (or async_approvals=True, asyncApprovals: true, WithAsyncApprovals()) to put every call in async mode. Model calls never wait for approval. Details, four-eyes approvals and user approval on the user's own device are in Human approvals.

Step 4: Report how the run ended#

Tell MAQPNA the result at the end of every run, including runs that ended because of a denial. It appears in the timeline and, in a cluster, as the session's result (status.outcome).

from maqpna import Result, truncate_utf8
client.report_result(Result.FAILED, truncate_utf8("stopped: budget_exceeded:agent/day"))
import { truncateUtf8 } from "@kosa8/maqpna";
await client.reportResult("Failed", truncateUtf8("stopped: budget_exceeded:agent/day"));
_, err := client.ReportResult(ctx, maqpna.Failed,
    maqpna.TruncateUTF8("stopped: budget_exceeded:agent/day", maqpna.MaxResultSummaryBytes))
curl -sS -X POST "$MAQPNA_GATEWAY_URL/v1/sessions/self/result" \
  -H "Authorization: Bearer $MAQPNA_TOKEN" -H 'Content-Type: application/json' \
  -d '{"result":"Succeeded","summary":"curl test"}'
{"annotated":false,"at":"2026-10-18T21:31:58.445474Z","auditSeq":10,"namespace":"dev","redacted":false,"result":"Succeeded","session":"dev-curl2","summary":"curl test"}

The result is Succeeded or Failed; the summary is at most 1024 bytes and passes through DLP. annotated is false under maqpna dev, where there is no AgentSession to annotate.

Next steps#