Executions
The execution API is in Preview: it can change or be removed at any time. Today:
- It runs only against Mandaitor's hosted sandboxes. A connector to your own system is refused.
- It has one tool, the release of a SAP purchase requisition, and that tool cannot be enabled yet. Its business action,
procurement.requisition.release, comes from the procurement taxonomy, which is not loaded yet. Until it is,GET /toolslists the tool as unavailable, and every request for it is refused with403 TOOL_NOT_ENABLED. - The SDK's executions client and receipt verifier are in
@mandaitor/sdk5.0.0, which is not published yet.
Beta Limitations lists the rest.
With POST /verify and POST /mcp/authorize, Mandaitor answers whether an agent may act, and the agent then acts with its own credentials. With the execution API, the agent asks Mandaitor to act instead. The agent names the operation and the record. Mandaitor reads the record from the target system, decides on what it read, performs the write with a credential the agent never holds, reads the result back, and signs a receipt of the outcome.
How an execution runs
- The agent sends a
mandaitor.tool-request/v1: the tool, and the record as an identifier. - Mandaitor normalizes the arguments and reads the record from the target system.
- It decides on the facts it read, such as the amount and the plant, against every mandate that names the agent. The agent never states those facts, so it cannot understate an amount.
- If a person must approve, the approval is bound to a digest of exactly what was decided: the record at its version, the facts, the mandates and the tenant's execution policy.
- It answers as soon as the decision is recorded. An allowed request is answered in
AUTHORIZED_NOT_EXECUTED: nothing has been written yet. - Mandaitor then reads the record again, decides again, and records the dispatch on the audit trail. Then it writes once, usually within seconds of the answer.
- It reads the record back to establish the outcome, and signs a receipt of it. The agent reads the outcome with
GET /executions/{id}.
A denial is not an error. It is an execution in DENIED, answered with 201, so the agent and the audit trail see the same object.
Setting up
A tenant administrator sets up four things, through the API or, where this section says so, the dashboard.
1. A sandbox connector
In the Preview, executions run only against Mandaitor's hosted sandboxes. Provision a SAP S/4HANA sandbox, which comes with a connector to it:
const connector = await client.post(`/tenants/${tenantId}/connectors/sandbox`, {
type: "sap-s4hana",
});
The sandbox emulates SAP's purchase requisition service, seeded with the requisitions listed under The SAP sandbox. It expires after 30 days, and its connector with it. Its credential is never shown to anyone: only Mandaitor's executor writes to the sandbox. In the dashboard, the Connectors page creates one as a demo sandbox.
2. The tool, enabled on that connector
GET /tools lists the registered tools and whether each is available. Enable a version of a tool on a connector of its type. This needs tenant:write:
await client.put(`/tenants/${tenantId}/tools/sap.purchase_requisition.release`, {
version: 1,
connector_id: connector.connector_id,
});
GET /tenants/{id}/tools returns the tenant's execution policy: which tool version runs on which connector. Every change raises the policy's version. An approval's digest records that version, so an approval still waiting when the policy changes must be given again. In the dashboard, the Connectors page lists the tools under Registered tools.
3. The agent, with its own key
The agent authenticates as itself. Register it with a public key (Agent Identity Registry). It opens a session at POST /mcp/token with a private_key_jwt assertion signed by that key (Token Exchange), and sends the session token as Authorization: Bearer mcp_…. The execution API refuses API keys, people's tokens and Proofs-of-Mandate.
4. A mandate written for executions
The decision reads every ACTIVE mandate in the tenant whose delegate is the agent. A mandate backs an execution only when all of these hold:
- A person stands behind it. Either a person's session created it, or it was created pending approval (
require_approval: true) and a person's session approved it. A mandate that an API key created on its own never lets an agent act. - It names the tool's business action exactly.
*and other patterns inscope.actionsgrant no execution. - Its resources cover the record. Resource patterns follow the taxonomy's grammar:
*matches within one/segment, and**matches everything below. - Its constraints use only the keys in the table below. Any other key makes the decision a denial with
UNSUPPORTED_CONSTRAINT, because Mandaitor does not act past a limit it cannot evaluate. That includesrate_limits,escalation_rules.deviation_above_percentand a non-emptyscope.conditions.
| Constraint | Effect |
|---|---|
expires_at (top level) and time.expires_at | The earlier one is in force. After it, nothing new is decided or written. |
time.not_before | Before it, a denial. |
transaction_limits.max_amount | A hard cap on the amount read from the record. Above it, a denial that no approval overrides. |
escalation_rules.amount_above | The approval threshold. Above it, a person must approve. |
escalation_rules.escalate_to | Who approves, as a canonical subject ID. Without it, the mandate's principal approves. |
require_mfa | Every execution under the mandate needs an approval given with MFA. |
Money is written as { "currency": "EUR", "value": "10000.00" }: an ISO 4217 code and a decimal string. Amounts are compared exactly. A fact in another currency is a denial (CURRENCY_MISMATCH), and there is no conversion.
The approver is named by their canonical subject ID. For someone who signs in to the dashboard, that is cognito: followed by their user ID. An approver who cannot sign in as that subject can never decide, and the approvals expire.
The dashboard's mandate form does not write constraints yet, so create this mandate through the API:
const mandate = await client.post("/mandates", {
principal: {
type: "NATURAL_PERSON",
subject_id: "cognito:a1b2c3d4-e5f6",
display_name: "Procurement lead",
},
delegate: {
type: "AGENT",
subject_id: agent.did,
display_name: "Procurement agent",
},
scope: {
actions: ["procurement.requisition.release"],
resources: ["procurement:plant:1000/requisition:*"],
effect: "ALLOW",
},
constraints: {
time: { expires_at: "2027-01-31T00:00:00Z" },
transaction_limits: { max_amount: { currency: "EUR", value: "100000.00" } },
escalation_rules: { amount_above: { currency: "EUR", value: "10000.00" } },
},
require_approval: true,
});
// POST /mcp/token opens a session only for an agent bound to a mandate delegated to it.
await client.post(`/agents/${agent.agent_id}/bind`, {
mandate_id: mandate.mandate_id,
});
Created with an API key, as here, the mandate waits for a person's approval. A person with mandates:write then approves it from their own session, on the dashboard's Governance page or with POST /mandates/{id}/approve, and from then on it counts. Under this mandate, the agent may release requisitions of plant 1000 up to EUR 10,000 on its own. Above that, the procurement lead approves each release, and above EUR 100,000 nothing is released.
Requesting an execution
The agent sends POST /executions with its session token:
// One key per request, sent again unchanged on every retry of it.
const idempotencyKey = crypto.randomUUID();
const response = await fetch("https://api.mandaitor.io/v1/executions", {
method: "POST",
headers: {
Authorization: `Bearer ${sessionToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
contract: "mandaitor.tool-request/v1",
tool: "sap.purchase_requisition.release",
arguments: { purchase_requisition: "10000124" },
idempotency_key: idempotencyKey,
purpose: "Retrofit approved in the Q4 capex review",
}),
});
const execution = await response.json();
| Field | Rules |
|---|---|
contract | Exactly mandaitor.tool-request/v1. Any other value is refused. |
tool | A tool the tenant has enabled. tool_version may pin a version, which must be the enabled one. |
arguments | Identifiers only, checked against the tool's argument schema. An unknown key is refused, not ignored. |
idempotency_key | Chosen by the agent. See Idempotency. |
mandate_id | Optional: the mandate the agent believes covers the call. It is a hint. If it does not cover the call, the answer is a denial. |
purpose | Optional free text, shown to the approver. It is never used to decide. |
The answer is the execution. 201 means the request created it, whatever its state:
{
"contract": "mandaitor.execution/v1",
"execution_id": "exe_01J9ZK3Q7V8D2M5N6P4R1S0T9W",
"state": "AWAITING_APPROVAL",
"state_reason": "APPROVAL_REQUIRED",
"tool": { "id": "sap.purchase_requisition.release", "version": 1 },
"request_digest": "sha-256:rHD2Q5K8WAZbfCBLAo7DahfycsRD2SZmruc-vwPqaJM",
"decision": {
"event_id": "evt_01J9ZK3Q7X0A1B2C3D4E5F6G7H",
"outcome": "REQUIRE_APPROVAL",
"mandate_id": "mdt_01J9PROCUREMENT",
"reason": "amount 38500.00 EUR is above the 10000.00 EUR approval threshold"
},
"approval": {
"consent_id": "cns_01J9ZK3Q7Y2C3D4E5F6G7H8J9K",
"digest": "sha-256:xOHyKqgWJ5RyB02kTXaav6Vm_dd5L7toyJHMYQp1qLc",
"expires_at": "2026-10-13T09:12:44Z"
},
"result": null,
"receipt": null,
"created_at": "2026-10-12T09:12:44Z",
"updated_at": "2026-10-12T09:12:44Z"
}
result holds only the fields the tool declares as returnable, and is set in SUCCEEDED. For the SAP release, those are purchase_requisition and release_state. receipt is set once the outcome is established. Mandaitor keeps the facts the decision rested on, and digests of everything else it read. It never keeps the full record, and nothing it returns or logs carries the target's credential.
An agent reads its own executions with GET /executions/{id}. Another agent's execution, or another tenant's, is 404.
Waiting for the outcome
An allowed request is answered before anything is written, in AUTHORIZED_NOT_EXECUTED. Mandaitor writes it straight after, so read the execution again every few seconds until it is final (DENIED, SUCCEEDED, FAILED), waits for an approval, or is UNKNOWN:
let current = await response.json();
while (["AUTHORIZED_NOT_EXECUTED", "EXECUTING"].includes(current.state)) {
await new Promise((resolve) => setTimeout(resolve, 2_000));
const read = await fetch(`https://api.mandaitor.io/v1/executions/${current.execution_id}`, {
headers: { Authorization: `Bearer ${sessionToken}` },
});
current = await read.json();
}
An UNKNOWN execution is not worth waiting on in a loop: Mandaitor keeps reading it back for about 41 hours (see States). Nothing notifies the agent when an execution changes; it reads.
Idempotency
(tenant, agent, idempotency_key) names at most one execution, until 24 hours after the execution reaches a final state.
- Sending the same request again with the same key returns that execution in its current state, with
200. - The same key with a different request is
409 IDEMPOTENCY_CONFLICT. - A repeat that arrives while the first request is still being decided is
409 IDEMPOTENCY_IN_PROGRESS, withRetry-After.
A retry with the same key never makes a second decision or a second write. The key Mandaitor passes to the target system comes from the execution, not from the agent, so two executions can never collide at the target.
With the SDK
@mandaitor/sdk 5.0.0, not yet published, carries a client that opens the agent's session, renews it when it ends, and retries only what the API marks as retryable, always with the same idempotency key:
import { MandaitorExecutionsClient, verifyReceiptForExecution } from "@mandaitor/sdk";
const executions = new MandaitorExecutionsClient({
baseUrl: "https://api.mandaitor.io/v1",
agent: { did: agentDid, keyId, privateKey: pkcs8Pem },
});
const { execution: requested } = await executions.requestExecution({
tool: "sap.purchase_requisition.release",
arguments: { purchase_requisition: "10000123" },
idempotency_key: crypto.randomUUID(),
});
// Reads it until it is final, waits for an approval, or is UNKNOWN.
const execution = await executions.waitForExecution(requested.execution_id);
if (execution.state === "SUCCEEDED") {
const receipt = await verifyReceiptForExecution(execution, {
allowedIssuers: ["did:web:api.mandaitor.io:v1"],
});
}
States
| State | Meaning | Final |
|---|---|---|
DENIED | Mandaitor will not perform this request. The target was not written. | Yes |
AWAITING_APPROVAL | Covered by a mandate, but a person must approve this exact request first. | No |
AUTHORIZED_NOT_EXECUTED | Allowed, and approved if that was required. Not yet written. | No |
EXECUTING | The dispatch is recorded, and the write is in flight. | No |
SUCCEEDED | The target applied the operation, and a read-back confirmed it. | Yes |
FAILED | The target did not apply it, established by a refusal and a read-back. | Yes |
UNKNOWN | Dispatched, but whether the write happened is not established: a timeout, a transport error, or a read-back that showed neither state. | No |
state_reason says why an execution is in its state, such as AMOUNT_ABOVE_LIMIT, NO_COVERING_MANDATE, APPROVAL_EXPIRED or RESOLVED_BY_READ_BACK. The API reference lists every reason by state. Treat a reason you do not know by its state's general meaning: new features add reasons.
Nothing is written twice. Mandaitor reads an UNKNOWN execution back once, right away, and that often settles it. If it does not, Mandaitor reads it back again a minute later, then at growing intervals: 5 and 15 minutes, then 1, 4, 12 and 24 hours after the read-back before. That is seven more read-backs over about 41 hours. It settles by reading, never by writing again. A read-back that finds the write makes the execution SUCCEEDED, and one that proves the write absent makes it FAILED, both with a receipt; the agent then asks again if it still wants the operation. If the target said it applied the write and a read-back cannot see it, Mandaitor never makes the execution FAILED on its own. What no read-back settles, a person of the tenant resolves: see Resolving an UNKNOWN execution.
Revocation stops everything not yet written. The decision is checked again immediately before the write. A mandate revoked or suspended, or an agent suspended, before that moment means no write. A write already dispatched cannot be recalled.
An allowed execution is written after the answer, usually within seconds. If Mandaitor cannot record the dispatch on the audit trail, it does not write. The execution then waits in AUTHORIZED_NOT_EXECUTED, and Mandaitor takes it up again within a minute or two. An allowed decision that has not led to a write within 15 minutes is denied with DECISION_EXPIRED.
Approvals
A person must approve when the amount read from the record is above the mandate's escalation_rules.amount_above, when the mandate requires MFA, or when the taxonomy marks the business action as requiring human approval.
- Who approves. The person the mandate names in
escalate_to, or else its principal. Only that person can decide, and only from their own signed-in session: an API key or an agent is refused with403 NOT_A_PERSON, and anyone else with403 NOT_THE_APPROVER. When the mandate requires MFA, the session must have been signed in with it. - Where. In the dashboard, the approval appears as a card in the Consent inbox and on the execution's page. The card shows the record at the version read, the facts, why approval is needed, the digest and the expiry. Through the API, the approval appears in
GET /consents/pendingwith anexecutionfield (Execution Approvals). - What is approved. The approver sends back the digest they were shown:
await approverClient.post(`/executions/${executionId}/approval`, {
decision: "APPROVE",
approval_digest: "sha-256:xOHyKqgWJ5RyB02kTXaav6Vm_dd5L7toyJHMYQp1qLc",
consent_id: "cns_01J9ZK3Q7Y2C3D4E5F6G7H8J9K",
});
Mandaitor reads the record again and approves only if three digests are equal: the one sent, the one the approval was raised with, and one computed from the fresh read. It answers with the execution approved, in AUTHORIZED_NOT_EXECUTED, and then writes and reads it back, as for a request that needed no approval; the execution's page in the dashboard shows the outcome. If anything the approval covered has changed, nothing is approved:
- If the request still needs an approval, a new one replaces the old, with its own digest, and the answer is
409 DIGEST_MISMATCH. - If the record changed so that no approval is needed any more, the execution is denied with
RECORD_CHANGED, and the agent asks again. An approval is never spent on a record nobody approved.
Sending { "decision": "REJECT" } rejects it, and no digest is needed. An optional comment goes with either decision.
- Expiry. An approval lasts 24 hours at most, and never past the mandate's own expiry. An approval not decided in time ends the execution as
DENIEDwithAPPROVAL_EXPIRED, and a decision sent afterwards is refused with409 APPROVAL_EXPIRED. - No consent policy approves an execution. A tenant's consent policies never resolve one. The threshold below which no approval is needed lives in the mandate, where the principal set it.
- A limit per agent. An agent may raise 20 approval requests per UTC day. Past that, a request that needs one is denied with
APPROVAL_LIMIT_REACHED.
Receipts
The receipt is Mandaitor's signed statement of an outcome, as the Proof-of-Mandate is its statement of an authorization. An execution gets one when a read-back establishes the outcome: in SUCCEEDED, and in FAILED once the read-back proves that nothing was written. A denial has none: its record is the decision event on the audit trail. Nor does a FAILED execution whose write was never sent (NOT_ATTEMPTED), one still UNKNOWN, or one a person resolved (RESOLVED_BY_OPERATOR): Mandaitor signs only what it read back.
A receipt is a compact JWS:
- The header carries
typ: "mandaitor-receipt+jwt",alg: "PS256", and akidof<issuer>#receipt-key-1. - The key is used for receipts only, and only Mandaitor's executor can sign with it. It is published in the issuer's DID document,
did:web:api.mandaitor.io:v1, which resolves tohttps://api.mandaitor.io/v1/did.json.
| Claim | Content |
|---|---|
iss, iat, jti | The issuer's DID, the issue time, and the receipt's ID. |
sub | The agent's DID. |
contract | mandaitor.execution-receipt/v1 |
tenant_id, execution_id | The execution. |
state | SUCCEEDED or FAILED. |
tool | { id, version } |
request_digest | The execution's request digest. |
approval | { consent_id, digest, decided_by, decided_at }, when a person approved. |
authorization | { event_id, mandate_id, pom_digest }: the dispatch event, the mandate it relied on, and the SHA-256 of the Proof-of-Mandate that authorized the write. |
target | { connector_id, system, record, version_before, version_after, transaction_ref }. transaction_ref appears only where the target system has a reference of its own. A SAP requisition release has none, so the record and version_after identify the write. |
timeline | { decided_at, dispatched_at, completed_at } |
result_digest | In SUCCEEDED, the digest of the result the agent was given. |
To verify a receipt without the SDK:
- Check that
typ,contract,algandkidare exactly as above, before you fetch anything. A Proof-of-Mandate, or anything signed with the Proof-of-Mandate key, is not a receipt. - Resolve the issuer's DID document and verify the signature with the key
kidnames, which the document must list as an assertion method. - Check that
execution_id,request_digestandstatematch the execution you were given. - In
SUCCEEDED, check thatresult_digestissha-256:followed by the unpadded base64url SHA-256 of the result, serialized with the JSON Canonicalization Scheme (RFC 8785).
The agent never receives the Proof-of-Mandate: Mandaitor performs the write, so a Proof-of-Mandate in the agent's hands would only be an authorization it could take elsewhere. Both the Proof-of-Mandate and the receipt go on the mandate's audit trail, and the mandate's evidence pack lists them in proof_tokens.
Reading executions
| Who | How |
|---|---|
| The agent | GET /executions/{id} with its session token: its own executions only. |
| People of the tenant | The dashboard's Executions page, under "Govern agents", and an execution's page. Through the API, GET /tenants/{id}/executions and GET /tenants/{id}/executions/{executionId}. |
The tenant routes need tenant:read, which every tenant role has and an API key never has. They show what each execution rested on: the agent, the arguments and purpose, the facts read from the record and its version, who had to approve and who did, what the write was dispatched on, and what the target said. They never show the agent's session or its idempotency key.
GET /tenants/{id}/executions lists the tenant's executions newest first, with an optional state filter, limit (1 to 50, default 20) and cursor. Mandaitor filters after it reads, so a page can hold fewer than limit executions, even none, and still carry a next_cursor. The list has ended only when there is no next_cursor. ?state=UNKNOWN lists the executions waiting to be settled; each shows its read-backs in reconciliation, and next_at is null once they are spent.
Resolving an UNKNOWN execution
When no read-back settles an UNKNOWN execution, a person of the tenant looks at the target system and records what they found. This needs tenant:write, which the tenant_admin role has, from the person's own signed-in session: an API key or an agent is refused with 403 NOT_A_PERSON. The dashboard does not offer it yet.
The body is the outcome you found, SUCCEEDED or FAILED, the evidence for it (what you checked and what you saw, up to 2,000 characters), and, optionally, the target's own transaction_ref:
await adminClient.post(`/tenants/${tenantId}/executions/${executionId}/resolution`, {
outcome: "FAILED",
evidence: "ME53N shows 10000124 not released; its change log has no entry after 12:00",
});
- The target's record comes first. Mandaitor reads the record back once more before it records anything. If that settles the execution, it is resolved as the read-back found, with a receipt, whatever you sent; when that differs from your
outcome, the answer is409 OUTCOME_CONTRADICTEDwith the execution as it now is. - Otherwise your outcome stands, as
SUCCEEDEDorFAILEDwithRESOLVED_BY_OPERATOR. Your evidence, andtransaction_refif you found the target's own reference, are kept in the execution'sresolutionand on the audit trail. It has no receipt. FAILEDinvites a second write. The agent learns the operation did not happen and may ask again. Be sure the first write is absent before you say so.- You need not wait for the read-backs to end. A requisition someone released by hand, for instance, never settles by waiting: the read-back finds it released, but not by this execution.
The SAP sandbox
A SAP S/4HANA sandbox is seeded with these purchase requisitions. Under the example mandate above, each one shows a different outcome:
| Requisition | Plant | Amount | Outcome under the example mandate |
|---|---|---|---|
10000123 | 1000 | EUR 4,850.00 | SUCCEEDED: within the mandate. |
10000124 | 1000 | EUR 38,500.00 | AWAITING_APPROVAL: above the approval threshold. |
10000125 | 1000 | EUR 240,000.00 | DENIED with AMOUNT_ABOVE_LIMIT: above the hard cap. |
10000126 | 2000 | EUR 3,200.00 | DENIED with NO_COVERING_MANDATE: another plant. |
10000127 | 1000 | EUR 950.00 | A fault: the release is made, then the answer times out. The read-back settles it as SUCCEEDED with RESOLVED_BY_READ_BACK. |
10000128 | 1000 | EUR 620.00 | A fault: the target answers 503 and changes nothing. The read-back settles it as FAILED. |
Each requisition can be released once. A second request for a released requisition is denied with PRECONDITION_FAILED. POST /tenants/{id}/connectors/{connectorId}/reset restores the seeded records, so the scenarios can run again.
Errors
| Status | error | When |
|---|---|---|
| 400 | INVALID_REQUEST | Not a valid tool-request/v1: an unknown key or another contract. errors lists each problem. |
| 400 | INVALID_ARGUMENTS | The arguments do not match the tool's schema. |
| 401 | invalid_token | The session is missing, expired or ended. API keys and people's tokens are refused here too. |
| 403 | TOOL_NOT_ENABLED | The tool is not enabled for the tenant, or does not exist: the answer is the same, so it does not reveal the registry. |
| 404 | EXECUTION_NOT_FOUND | Not one of this agent's executions. |
| 409 | IDEMPOTENCY_CONFLICT | The same key with a different request. |
| 409 | IDEMPOTENCY_IN_PROGRESS | The same key while its request is still being decided. Retry after Retry-After. |
| 429 | RATE_LIMITED | More than 30 requests from this agent in a minute. Retry after Retry-After. |
| 503 | TARGET_UNAVAILABLE | The record could not be read, so nothing was decided or recorded. Retry with the same key. |
| 503 | AUDIT_UNAVAILABLE | The decision could not be recorded on the audit trail, so nothing changed. Retry with the same key. |
POST /executions/{id}/approval answers 403 NOT_A_PERSON, NOT_THE_APPROVER or MFA_REQUIRED, and 409 with DIGEST_MISMATCH, APPROVAL_SUPERSEDED, APPROVAL_EXPIRED, APPROVAL_IN_PROGRESS or EXECUTION_DENIED. A 409 carries the execution as it now is.
POST /tenants/{id}/executions/{executionId}/resolution answers 400 INVALID_REQUEST for any other body, 403 without tenant:write or a person's session (NOT_A_PERSON), 404 EXECUTION_NOT_FOUND, 409 with OUTCOME_CONTRADICTED, NOT_UNKNOWN or RESOLUTION_IN_PROGRESS, and 503 AUDIT_UNAVAILABLE. The API reference describes each.