Documentation

Build with A2SPA

Everything you need to put an approval check in front of your agents' actions: quickstart, API reference, SDKs and the rules behind every decision.

Overview

A2SPA checks every action an AI agent wants to take before your systems run it. Your agent describes the action and signs it; A2SPA checks who sent it, that it wasn't changed, that it's current and that your rules allow it, then returns a signed decision. Your system acts only when the decision is authorized.

A2SPA doesn't build or run agents. You create a wrapper for each agent you already run. The wrapper holds the agent's signing key and rules.

Quickstart

  1. Create an account and open the dashboard.
  2. Create a wrapper for your agent (Dashboard → Wrappers → Create wrapper). Save the private key it shows; it's shown only once. If your agent sends actions to another system, create a wrapper for that system too and turn on Receive actions.
  3. Copy your API key (Dashboard → API key & setup). Store both secrets in a secret manager.
  4. Install the SDK: pip install a2spa (Python 3.11–3.13) or npm install a2spa (Node.js 22+).
  5. Check each action before it runs:
import os, requests
from a2spa import (build_payload_fields, build_signed_request,
                   policy_input, require_authorized_result)

API = "https://api.aimodularity.com/A2SPA"

action = build_payload_fields(
    agent_id=os.environ["A2SPA_WRAPPER_ID"],
    target_agent_id=os.environ["A2SPA_TARGET_ID"],
    input_data=policy_input(action="export_customer_records",
                            workflow_scope="privacy:export",
                            customer_id="10442"),
)
body = build_signed_request(os.environ["A2SPA_PRIVATE_KEY"], action)
result = requests.post(f"{API}/v1/verify_payload", json=body, timeout=10,
                       headers={"x-api-key": os.environ["A2SPA_API_KEY"]}).json()

require_authorized_result(result)   # raises A2SPAAuthorizationError unless authorized
export_customer("10442")            # only reached when approved

That's it. The decision appears in Dashboard → Activity, with its signed receipt.

Concepts

Wrapper
Connects one agent you run to A2SPA. It has an ID, a signing key, permissions (can it request actions, can it receive them) and rules. Wrappers are created in the dashboard; the private key is shown once.
Action
What the agent wants to do, described as a signed JSON payload: who it's from, who it's for, the details, a timestamp and a one-time value.
Decision
authorized, refer_hold (held for review) or denied. Only authorized means "go". A request that fails a check before a decision is reached gets an error instead.
Receipt
A record of the decision, signed by A2SPA (RSA-PSS-SHA256), that you can store and verify later.
Rules
Per-wrapper policy: which actions, scopes, targets, currencies and amounts are allowed.

Authentication & base URL

Base URLhttps://api.aimodularity.com/A2SPA
AuthHeader x-api-key: <your API key> on every request
FormatJSON, Content-Type: application/json
VersioningCanonical routes are under /v1. Older /api/* routes remain as aliases during the migration window.
ContractOpenAPI 3.1 specification

The API key identifies your account; the wrapper's private key proves each action. Both must stay secret. Never ship either in client-side code.

The signed action

Every check sends {"payload": {...}, "signature": "<hex>"}. The SDK's build_payload_fields and build_signed_request fill this in for you.

FieldRequiredDescription
agent_idYesThe sending wrapper's ID.
target_agent_idYesThe receiving wrapper's ID.
timestampYesISO 8601 time the action was signed. Must be within 120 seconds of A2SPA's clock.
nonceYesOne-time value, 8–512 characters (A-Z a-z 0-9 _ . : -). Never reuse it.
inputYesThe action details (object or string). Put the facts your rules check here: action, workflow_scope, amount_usd, currency, on_behalf_of, plus your own fields.
outputYesExpected result or status (object or string).
hashYesSHA-256 (hex) of the signed bytes. See Signing.
alert_thresholdNoLow-balance warning threshold, 0–1,000,000. Default 10.
crypto_profileNoSignature profile. Default a2spa.classical.rsa_pkcs1v15_sha256.v1.
operation_idNoYour idempotency key (up to 160 characters) for safe retries.
state_continuityNoTies the action to registered state. See continuity_state.
delegationNoSigned delegation proof when acting on someone else's behalf.

Signing

The SDKs handle signing. If you implement it yourself, the signed bytes are UTF-8 JSON of the signable fields with keys sorted by Unicode code point, Python-style separators (", " and ": ") and non-ASCII characters escaped as \uXXXX.

  • Signable fields: agent_id, target_agent_id, timestamp, nonce, input, output, alert_threshold (default 10), and, when present, crypto_profile, state_continuity, operation_id, delegation.
  • hash = hex SHA-256 of the signed bytes.
  • signature = hex signature of the same bytes with the wrapper's private key.
  • Numbers: the JavaScript SDK accepts safe integers only. Send decimals as strings, for example "amount_usd": "49.00".
ProfileAlgorithm
a2spa.classical.rsa_pkcs1v15_sha256.v1RSA PKCS#1 v1.5, SHA-256 (default)
a2spa.pq.mldsa.v1ML-DSA post-quantum signatures (optional)
a2spa.legacy.rsa_pkcs1v15_sha256.v1Legacy compatibility

POST /v1/verify_payload

Checks a signed action and returns a decision. Never infer approval from the HTTP status alone. Use require_authorized_result / requireAuthorizedResult, which pass only an explicit authorized decision.

// 200: approved
{
  "success": true,
  "receipt": {
    "receipt": {
      "receipt_id": "rct_...",
      "decision": "authorized",
      "agent_id": "...",
      "target_agent_id": "...",
      "issued_at": "..."
    },
    "canonical_hash": "...",
    "signature": "...",
    "signature_algorithm": "RSA-PSS-SHA256",
    "key_id": "..."
  }
}
// 403: denied before a decision
{
  "error": "Policy denied - workflow scope not allowed"
}

// 409: one-time value reused
{
  "error": "Replay attack detected - nonce already used"
}

// 429: slow down
{
  "error": "Rate limit exceeded",
  "retry_after": 12
}
StatusMeaning
200A decision was made. It may be authorized, refer_hold or denied: check it.
400Malformed request, bad hash or signature, or timestamp outside 120 seconds.
401Missing or invalid API key.
403Not allowed: rules, permissions, an inactive wrapper or target, delegation, or insufficient credits.
409One-time value reused with different content, an operation still in progress, or a state conflict.
429Rate limited. Wait retry_after seconds.
500 / 503Temporary server problem. Treat as "do not run".

Decisions & receipts

DecisionYour system should
authorizedRun the action. Store the receipt ID with it.
refer_holdDon't run it. Route it to a person for review.
deniedDon't run it.
Error / timeoutDon't run it. Retry safely (see Errors).

GET /v1/receipts/{receipt_id}

Fetch a receipt your account owns, for example during an audit. Receipts are signed with RSA-PSS-SHA256; the Python SDK's verify_execution_receipt(receipt, trusted_public_key_pem) checks one offline against A2SPA's receipt public key (available on request).

curl -H "x-api-key: $A2SPA_API_KEY" \
  https://api.aimodularity.com/A2SPA/v1/receipts/rct_...

POST /v1/continuity_state

Register a fact that must still be true when an action runs, such as money reserved, stock available or an approval still valid. Actions that reference the state in state_continuity are approved only while it matches, and each sequence can be used once.

FieldRequiredDescription
state_idYesYour ID for the state (up to 160 characters).
sequenceYesInteger ≥ 0. Increase it when the state changes.
bindingYesWhich sender and target may use this state.
state or state_hashOne ofThe state JSON, or its SHA-256.
statusNoactive (default) or revoked.
expires_atNoISO 8601 expiry.

Inbox & logs

GET /v1/inbox_for_agent?agent_id=…&after=…&limit=…

Approved actions delivered to a receiving wrapper, oldest first. Poll with after set to the timestamp of the last message you processed; limit is 1–100 (default 50). Keep polling until you get an empty page.

GET /v1/logs_for_agent?agent_id=…

Recent decisions for audit and troubleshooting. Use the inbox, not logs, as your runtime delivery feed.

Errors & retries

  • Fail closed. Anything other than an explicit authorized decision means the action doesn't run, including timeouts.
  • Safe retries. Re-sending the exact same signed request returns the original decision and receipt; nothing is approved twice. Use operation_id for retries that rebuild the request.
  • Never reuse a one-time value with different content: that's treated as a replay (409).
  • Rate limits apply per API key and per IP. On 429, wait retry_after seconds.
  • Clock skew. Keep servers on NTP; timestamps more than 120 seconds off are refused.

Rule (policy) reason codes

When rules deny an action, these stable codes may appear in decisions and evidence:

POLICY_TARGET_MISMATCHPOLICY_ACTION_MISMATCHPOLICY_RESOURCE_MISMATCHPOLICY_PARAMETER_MISMATCHPOLICY_PAYLOAD_MISMATCHPOLICY_WORKFLOW_MISMATCHPOLICY_ENVIRONMENT_MISMATCHPOLICY_USAGE_EXHAUSTEDPOLICY_APPROVAL_REQUIREDPOLICY_UNSUPPORTED_REQUIRED_CONSTRAINTPOLICY_INDETERMINATE

Wrapper rules

Set rules per wrapper in Dashboard → Wrappers → Rules. Start from a preset (Basic, Scoped, Restricted, Exact action) or edit JSON. Rules compare the facts in the signed input against what's allowed.

{
  "allowed_actions": ["refund", "credit_note"],
  "allowed_workflow_scopes": ["billing:refunds"],
  "allowed_currencies": ["USD"],
  "max_spend_usd": 500
}
RuleChecks
allowed_actionsThe action fact.
allowed_workflow_scopesThe workflow_scope fact.
allowed_targets / allowed_sendersWho a wrapper may send to, or accept from.
allowed_intentsThe intent fact.
allowed_currencies, max_spend_usdThe currency and amount_usd facts.
policy_constraintsExact-action constraints: resource, environment, parameters, payload hash, usage limits.
delegationWhether, and for whom, the wrapper may act on someone's behalf.

SDKs

Python

pip install a2spa · Python 3.11–3.13

build_payload_fields, build_signed_request, policy_input, require_authorized_result, authorization_decision, verify_execution_receipt, execute_protected_action

Node.js

npm install a2spa · Node.js 22+

buildPayloadFields, buildSignedRequest, policyInput, requireAuthorizedResult, authorizationDecision, computePayloadHash

For step-by-step code, environment variables and templates, see the integration guide.

AI coding assistants

Give your coding assistant this prompt to add A2SPA to an existing repository:

Add A2SPA authorization to this repository using the published a2spa package
and the A2SPA integration guide (https://aimodularity.com/A2SPA/integrations).

- Every consequential action (payments, data exports, writes, deploys) must be
  signed and sent to POST /v1/verify_payload before it runs.
- Read the wrapper ID, private key and API key from environment variables or the
  secret manager. Never hard-code them.
- Proceed only when require_authorized_result passes. Treat errors, timeouts,
  refer_hold and denied as "do not run".
- Put the facts the rules check (action, workflow_scope, amount_usd, currency)
  in the signed input.
- Do not bypass the A2SPA API or write to its storage directly.

Security model

  • Each wrapper signs its own actions; A2SPA never holds your agents' private keys after issuing them.
  • Approvals bind the exact action: changing any signed field invalidates the signature.
  • Each approval is single-use and time-limited.
  • Decisions are signed receipts, verifiable independently.
  • Put the check where the action happens. A check beside the path can be walked around.
  • A2SPA doesn't judge whether an action is wise. Loose rules approve what they allow, and a stolen wrapper key can sign until you revoke it.

Report security issues to [email protected].

Versions

Hosted service and SDKs share a 2.4.x version line on protocol v1.0. Patch releases keep the v1 API contract unchanged. The current service reports its version in the X-A2SPA-Version response header.