# Parmana: integration documentation in full > Parmana is an authorization layer between a caller (an AI agent, a script or a person) and the business systems that carry out an action. The caller sends a Business Transaction that describes the intended action. A policy engine decides. If the action is approved, Parmana signs an authorization, releases the action to the connector, and returns a signed Execution Trust Record that anyone can verify without trusting Parmana. The caller never holds the credentials for the target system. This file concatenates the integration pages of the Parmana documentation. It is generated. Do not edit it by hand. Regenerate it with `npm run generate:llms-txt`. The index of every page is at https://docs.parmanasystems.com/llms.txt. --- ## Welcome Source: https://docs.parmanasystems.com Parmana sits between an AI agent (or a script, or a person) and the systems that carry out its actions. Before anything executes, Parmana checks it against a policy you write and issues a signed, single-use authorization. After it executes, you have an independently verifiable signed record of exactly what happened. The [Quickstart](/quickstart) runs a local server step by step. **Proprietary software, evaluation only.** You may read, build and run Parmana to assess it; any other use needs a written agreement. The client and connector SDKs are the exception: they are open source under the Apache License 2.0. See [License and evaluation](/license). Evaluators and auditors start at [Evaluate Parmana](/evaluation/overview) and the [Audit guide](/evaluation/audit-guide). **Try it now, with nothing to install.** The [Playground](/playground) is a live Parmana sandbox with a published demo key: send a request, watch the policy refuse it, get a signed approval, send it again, and verify the signed record yourself. Every **Try it** panel in the [REST API](/api-reference/introduction) tab calls the sandbox with the demo key filled in. **AI agent reading this?** `GET /api-manifest.json` on any deployment is a single machine-readable index of the OpenAPI spec, auth scheme, and both real SDKs with their actual current package versions, no page-scraping required. To call a live server right away, use the public sandbox at `https://parmana-sandbox.vercel.app` with the demo key on the [Playground](/playground). The [Quickstart](/quickstart) then gets you a working local server and a real, committed test credential (not a production secret) to call it with in under 10 minutes. ## What Parmana is Think of how a card network authorizes a purchase. Visa doesn't stock the shelves or move the money itself. It decides, quickly and cryptographically, whether the transaction is allowed, then hands the merchant's bank a signed authorization to act on. **Parmana plays that role for automated business actions.** An agent proposes an action. Parmana's policy engine decides, deterministically, whether it's allowed. If approved, Parmana issues a signed, single-use, time-bounded authorization. Your own systems, through a Connector, are what actually execute it. Parmana never touches your systems directly, the same way a card network never touches the goods on the shelf. **No AI agent action is authorized without a signed human approval, reads included.** A policy can only approve when a trusted person has signed an approval for that action on that resource, and the approval is checked, not taken on the agent's word. See [Human approval](/concepts/human-approval). ## The trust chain, at a glance ``` Caller (AI agent, script, or person) │ proposes a Business Transaction ▼ Policy Engine ──deterministic decision──► APPROVED or REJECTED │ (no matching rule denies by default: fail closed) ▼ if APPROVED Signed Execution Authorization (single-use, time-bounded, content-bound) │ ▼ Execution Gateway ──independently re-verifies the signed envelope──► │ ├─► Session Credential Vault (issues a scoped, single-use credential) ▼ Connector (your system: an HTTP call, or a reference mock today) │ ▼ Execution Trust Record (signed, independently verifiable) ``` Every stage in that chain is real, tested code, not a diagram of intent. The default local server (`packages/api/src/server.ts`) wires the Execution Gateway unconditionally: there is no code path where a request skips it. See [How Parmana thinks](/how-parmana-thinks) for what each stage guarantees and why. ## Status tags used throughout this site Built and tested in this repo today. Cites the source file(s) and/or test(s) that prove it. Exists, but incomplete. States exactly what works and what doesn't. Designed, not yet built. Says so in the first sentence, never documented as if it ships. A page or section with no tag is a bug in this site, [file it as such](/contributing). ## Where to go next Install to first authorized execution in under 10 minutes, using the in-memory server. Run Parmana on your own infrastructure with one Docker Compose command. Decisions, keys and records stay in your network. The concepts behind the diagram above: policy, authorization, the gateway, trust records. The same flow against a real connector and real infrastructure, start to finish. TypeScript and Python, both published, both with a builder that removes the most common integration mistake. One spec, three views: this site's per-route reference, a live Swagger UI to try requests, and a read-only ReDoc page. Content on this site is periodically verified against a specific commit; see [Changelog](/changelog) for what changed and when. --- ## Choose your path Source: https://docs.parmanasystems.com/choose-your-path Find your goal in the table. Each row names one place to start, what you need before you begin, and the first command or request. If two rows seem to apply, do the earlier one first. ## What do you want to do? | Your goal | Start here | You need | First step | | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | **See Parmana work right now**, with nothing installed and no account | [Playground](/playground) | A browser, or cURL or PowerShell | `GET https://parmana-sandbox.vercel.app/callers/me` with the demo key | | Put Parmana in front of an AI agent's actions, from **TypeScript** | [TypeScript SDK quickstart](/guides/typescript-sdk-quickstart) | Node 20 or later, a Parmana API URL, an API key | `npm install @parmana/sdk` | | Put Parmana in front of an AI agent's actions, from **Python** | [Python SDK quickstart](/guides/python-sdk-quickstart) | Python 3.10 or later, a Parmana API URL, an API key | `pip install parmana` | | Integrate from **any other language**, or with a plain HTTP client | [REST API introduction](/api-reference/introduction) and [Authentication](/api-reference/authentication) | Any HTTP client, a Parmana API URL, an API key | `POST /execute` with a Bearer key | | Let Parmana release approved actions to a system you already use (HubSpot, GitHub, Slack, Paytm) | [Connectors overview](/integrations/overview) | A running Parmana server with that connector's credentials configured | Read the overview, then the connector's page | | Build a **new connector** for a system Parmana does not support yet | [Add a connector](/guides/add-a-connector) then the [Connector development guide](/integrations/connector-development-guide) | Node or Python, and the system's API credentials | `npm install @parmana/connector-sdk` or `pip install parmana-connector-sdk` | | **Run Parmana on your own infrastructure**, with its own Postgres, in one command | [Self hosted quickstart](/self-hosted/quickstart), after the [Self hosted overview](/self-hosted/overview) | Docker with Docker Compose v2, a clone of the repository | `docker compose up -d --build --wait` | | **Run Parmana from source** on your own machine, for development | [Local deployment](/deployment/local) | Node 24 or later | Follow the page top to bottom | | **Run Parmana in production** | [Production deployment](/deployment/production) and the [Environment variable reference](/deployment/environment-variables) | A host that runs Node 24 or later or a container, a Postgres database (Supabase), signing keys | Follow the page top to bottom | | Decide **what an agent is allowed to do** | [Write your first policy](/guides/write-your-first-policy) | A running Parmana server | Copy the example policy and change one rule | | **Approve or reject a change** to a policy | [Policy governance](/guides/policy-governance-maker-checker) | A human credential and a step up signing key | Read the page, then follow the approval steps | | **Check a record** someone gave you, as an auditor, without trusting Parmana | [Verify a trust record independently](/guides/verify-independently) | The record and Parmana's public key | Follow the page top to bottom | | Understand **why** Parmana works this way before you commit | [How Parmana thinks](/how-parmana-thinks) | Nothing | Read it, it is short | | See one **complete working flow** from agent to a real payment provider | [End to end: agent, Parmana, Paytm](/guides/end-to-end-paytm-flow) | A deployed Parmana and a Paytm staging account | Follow the page top to bottom | ## The three steps every integration does Whatever language you use, an integration is these three steps, in this order. 1. **Get a key.** Every request carries `Authorization: Bearer `. Keys are issued by whoever runs the Parmana server. See [Authentication](/api-reference/authentication). 2. **Send the action for a decision.** Describe what the agent wants to do as a Business Transaction and send it with `POST /execute`. Give it a fresh `businessTransactionId` (a UUID) every time, because that identifier is the idempotency key. See [Idempotency and nonces](/api-reference/idempotency-and-nonces). 3. **Act on the answer, and only on the answer.** Proceed only when the response is HTTP `200` and `executions[0].decision.outcome` is `APPROVED`. Treat everything else as "not authorized". The next section lists every answer. ## What each answer means, and what to do | You receive | Meaning | What to do | | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `200` and `decision.outcome` is `APPROVED` | Policy approved it, and the action was released to the connector. You get a signed Execution Trust Record. | Proceed. Keep the record. | | `403` with code `POLICY_DENIED` | A policy rule said no. This is a final decision. | Do not proceed. Do not retry the same request. | | `403` with no code | Your key is not allowed to assert that principal. | Fix the key's permissions. | | `403` with code `CAPABILITY_NOT_ALLOWED` | Your key is not allowed to invoke that action. | Fix the key's capabilities. | | `401` | The key is missing or wrong. | Fix the key. | | `400` | The request is malformed. | Fix the request. See the [error catalog](/api-reference/error-catalog). | | `409` | That `businessTransactionId` was already used. | Do not send it again. Look up the existing record instead. | | `429` | You are rate limited. | Wait for the time in the `Retry-After` header, then retry with a new `businessTransactionId`. | | `503` with code `SIGNING_UNAVAILABLE` | Parmana could not sign evidence, so it refused **before** releasing the action. **Nothing was executed.** | Retry later with a new `businessTransactionId`. | | `503` with code `EXECUTION_INTENT_UNAVAILABLE` | Parmana could not sign and store the [Execution Intent](/concepts/execution-intents), so it refused **before** releasing the action. **Nothing was executed.** | Retry later with a new `businessTransactionId`. | | `500` with code `EXECUTION_RECORD_INCOMPLETE` | The action **was** released, but its signed record could not be produced. | **Do not resubmit under a new id.** Tell the operator. A signed [Execution Intent](/concepts/execution-intents) exists, and the operator can rebuild the record without repeating the action. | | `502` with code `EXECUTION_OUTCOME_UNKNOWN` | The action was released and the connector call failed, so it **may** have been performed. | **Do not resubmit.** Check the target system, then close the Execution Intent with resolve. | | Any other `5xx`, or no response | Parmana's answer is unknown. | Treat it as not authorized. Never treat "could not reach Parmana" as "allowed". | The full list, with the exact messages, is the [error catalog](/api-reference/error-catalog). ## Which SDK? | You use | Package | Current version | Install | | -------------------------------------- | ------------------------------------------- | --------------- | ------------------------------------------------------------- | | TypeScript or JavaScript | [`@parmana/sdk`](/sdks/typescript) on npm | 1.6.0 | `npm install @parmana/sdk` | | Python | [`parmana`](/sdks/python) on PyPI | 1.6.0 | `pip install parmana` | | Python, and you verify records offline | `parmana` with the `verify` extra | 1.6.0 | `pip install "parmana[verify]"` | | Another language | The [REST API](/api-reference/introduction) | Not applicable | Any HTTP client, see [Other languages](/sdks/other-languages) | Both SDKs never retry `POST /execute` for you. They only retry idempotent `GET` requests, so an SDK can never turn a single action into two. ## For AI agents If you are an AI agent, do not use this page to decide what to do. Follow [Integrate Parmana: specification for AI agents](/agents/integrate) exactly. It is written to be followed literally, one step at a time, with what to expect and what to do when the result is different. It also has the same answer table as above, in the order to apply it. ## Not sure yet Try the [Playground](/playground) first: real requests to a live sandbox, nothing to install. Then read [How Parmana thinks](/how-parmana-thinks) and the [Quickstart](/quickstart). None of them needs an account. --- ## Integrate Parmana: specification for AI agents Source: https://docs.parmanasystems.com/agents/integrate This page is written to be followed literally by an AI agent or a person. It contains no options to weigh and no background to interpret. If your situation is not covered, **stop and ask the operator**. Do not guess. ## Rules that apply to every step 1. Do the steps in order. Do not skip one. 2. At each step, compare what you received with **Expect**. If it differs, follow **If not** and stop. 3. Never invent a value. Every input in the next section comes from the operator or from a response. 4. Never send an action to Parmana and then perform it yourself. Parmana releases approved actions to the connector. You only act on the answer. 5. Treat anything not explicitly approved as **not authorized**. ## Inputs you need before you start | Input | Where it comes from | How to check it | | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `PARMANA_URL` | The operator. The base URL of the Parmana server, without a trailing slash. | It must start with `https://`, except for a local server, which uses `http://localhost:3000`. | | `PARMANA_API_KEY` | The operator. A secret string issued for you. | It is non empty. Never print it, log it or put it in a URL. | | `APPROVAL_SIGNALS` | Step 3: the keys of `signals.approval` in the server's answer. The table in [Every action needs a signed approval](#every-action-needs-a-signed-approval) shows them for the connector actions. | It is never empty. Every action needs one, reads included. If the operator says none, stop: that is not possible on a current server. | | `ACTION` | The operator. The capability name, for example `paytm:refund`. | Step 2 checks your key may use it. | **No deployment yet? Practise on the public sandbox.** Use `PARMANA_URL` `https://parmana-sandbox.vercel.app`, `PARMANA_API_KEY` `2VfYWCzt_cBAPK-8uufX6ordfY2JuQhFPsohuEumKME` (the published demo key: it is public, so the rule about never printing a key does not protect anything for it, but keep the habit), and `ACTION` `sandbox:receipt`; step 3 gives `APPROVAL_SIGNALS` `receiptApproved`. Every step below is the same, except that in the sandbox you get the approval yourself from `POST /sandbox/approvals` (see [If the action needs a person's approval](#if-the-action-needs-a-persons-approval)). The sandbox acts on nothing and everything sent to it is visible to every visitor. See [Playground](/playground). Do not ask for a policy version. Step 3 reads the version in effect from the server, because an approved new version replaces the old one at once and a request naming an old version is refused. ## Every action needs a signed approval No action is authorized without a signed approval from a trusted person, for that action and that resource. This holds for reads as well as writes, and for every policy: a server refuses to load a policy that could approve without one. So plan for it from the start: **your first request for an action on a new resource will be refused** until a person signs an approval for it. For the actions with a connector, the approval signal and the resource it names are: | `ACTION` | Policy in effect (step 3 confirms) | Approval signal | Resource the approval names | Other signals the policy reads | | -------------------------------- | ---------------------------------- | -------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `paytm:refund` | `customer-refund` 1.2.0 | `managerApproved` | `intent.parameters.orderId`, up to `intent.parameters.amount` | `refundEligible`, `fraudCheckPassed`, `refundAmount` (equal to `intent.parameters.amount`) | | `github:pr-merge` | `github-pr-approval` 1.1.0 | `mergeApproved` | `intent.target` | `branchProtected`, `repositoryAuthorized`, `requiredReviewsCompleted`, `riskScore`, `statusChecksPassed` | | `github:pr-fetch` | `github-pr-read` 1.1.0 | `readApproved` | `intent.target` | none | | `hubspot:deal-update` | `hubspot-deal-update` 1.1.0 | `dealUpdateApproved` | `intent.parameters.dealId` | `dealStageChangeRequested`, `dealStageTransitionAllowed`; `proposedDealStage` and `proposedAmount` equal to `intent.parameters.dealstage` and `intent.parameters.amount` | | `hubspot:deal-fetch` | `hubspot-deal-read` 1.0.0 | `readApproved` | `intent.parameters.dealId` | none | | `slack:post-message` | `slack-post-message` 1.1.0 | `postApproved` | `intent.target` (the channel id) | `channelAuthorized`, `contentApproved`; `channelId` equal to `intent.target` | | `sandbox:receipt` (sandbox only) | `sandbox-receipt` 1.0.0 | `receiptApproved` | `intent.target` | none; `note` equal to `intent.parameters.note`, at most 200 characters | For any action, including one not in this table (such as an action an operator registered as an external connector, see [Connect any external system](/guides/connect-any-external-system)), step 3 returns the same information from the server: `signals.approval` names the approval signal and where its resource (and amount) are in the request. Use the server's answer over this table if they ever differ. An approval covers the action and the resource, and the amount where the table names one. It does not fix other parameters: a Slack approval does not fix the message text, and a HubSpot approval does not fix the new stage. Every request below carries this header: `Authorization: Bearer `. ## Step 1: check the server is reachable and safe **Do:** send `GET {PARMANA_URL}/health`, then `GET {PARMANA_URL}/ready`. Neither needs the header. **Expect:** 1. `/health` returns HTTP `200` and `{"status":"UP"}`. 2. `/ready` returns HTTP `200` and a body with `"status":"READY"` and `"authDisabled":false`. **If not:** 1. `/health` fails or returns anything else: stop. The server is not reachable. Tell the operator. 2. `/ready` returns HTTP `503` (`"status":"NOT_READY"`): stop. Its storage is not reachable. Tell the operator. 3. `/ready` shows `"authDisabled":true`: **stop and do not send any real action.** That server accepts requests from anyone. Tell the operator that authentication is switched off. ## Step 2: check what your key may do **Do:** send `GET {PARMANA_URL}/callers/me` with the header. **Expect:** HTTP `200` and a body like this: ```json { "callerId": "my-agent", "allowedPrincipalIds": ["my-agent"], "allowedCapabilities": ["paytm:refund"], "unrestrictedCapabilities": false } ``` Keep `callerId`. Your `authority.principalId` in step 4 must be one of `allowedPrincipalIds`, and the safe choice is `callerId`. Your `ACTION` must be in `allowedCapabilities`, or `unrestrictedCapabilities` must be `true`. **If not:** 1. HTTP `401`: the key is missing or wrong. Stop. Ask the operator for a valid key. 2. `allowedCapabilities` is empty and `unrestrictedCapabilities` is `false`: your key may invoke **nothing**. Stop. Ask the operator to grant the capability. 3. `ACTION` is not in `allowedCapabilities`: stop. Ask the operator to grant it. Do not use a different action. ## Step 3: find the policy in effect **Do:** send `GET {PARMANA_URL}/policies/in-effect?capability=` with the header. **Expect:** HTTP `200` and a body like: ```json { "capability": "paytm:refund", "policy": { "name": "customer-refund", "version": "1.2.0", "schemaVersion": "1.0.0" }, "description": "Authorizes customer refunds. Every refund above 0 and up to 100000 needs a signed manager approval for that order, covering that amount. ...", "signals": { "facts": [ "fraudCheckPassed", "managerApproved", "refundAmount", "refundEligible" ], "schema": { "refundEligible": "boolean", "managerApproved": "boolean", "fraudCheckPassed": "boolean", "refundAmount": "number" }, "bound": { "refundAmount": "parameters.amount" }, "approval": { "managerApproved": { "resourceId": "parameters.orderId", "value": "parameters.amount" } } } } ``` Use it like this in step 4: | Field in the answer | What you do with it | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `policy` | Send it, exactly as returned, as the request's `policy`. | | `signals.facts` | Send one entry in `signals` for **every** name in this list, with the type `signals.schema` gives it. A condition on a missing fact is always false, so leaving one out never helps. | | `signals.bound` | For each entry, the signal must equal the value at that path of your `intent` (for example `signals.refundAmount` equal to `intent.parameters.amount`). Set both from the same variable. | | `signals.approval` | Each key is an approval signal. Send it `false` until you hold a signed approval; with one, send it `true` and put the approval in `signals.approvalArtifact`. `resourceId` (and `value`) say where in your request the resource (and amount) the approval must name are: `target` means `intent.target`, `parameters.x` means `intent.parameters.x`. | | `description` | For people. Do not parse it. | The answer never contains the policy's rule conditions. The `description` is the author's text for people and may mention a limit; the server decides with the rules, not the text. Look it up again before every request: it changes when a new version is approved, with no notice to you. **If not:** HTTP `403` with code `CAPABILITY_NOT_ALLOWED` means your key may not use this action: stop and ask the operator. HTTP `404` with code `CAPABILITY_NOT_BOUND` means no policy governs this action on this server: stop and ask the operator. HTTP `409` with code `NO_APPROVED_POLICY_VERSION` or `503` with code `POLICY_VERSION_UNAVAILABLE` means every request for this action is refused right now: stop and tell the operator. Do not guess a policy. With an SDK: `client.policyInEffect(ACTION)` in TypeScript, `client.policy_in_effect(ACTION)` in Python. Both return this answer; the Python fields are `policy.schema_version` and `signals.facts`, and signal names keep their exact spelling. ## Step 4: build the Business Transaction A Business Transaction is one JSON object. Every field below is required unless it says otherwise. | Field | Type and rule | Who sets it | | ------------------------------------------------- | ------------------------------------------------------------------------------------------ | ----------- | | `businessTransactionId` | A new UUID, generated by you, **different for every request**. It is the idempotency key. | You | | `metadata.businessTransactionId` | The same UUID as above. | You | | `metadata.correlationId`, `metadata.sourceSystem` | Optional strings for your own tracing. | You | | `metadata.submittedBy` | Ignored if you send it. The server sets it from your key. | The server | | `authority.authorityId` | A new UUID. | You | | `authority.authorityType` | Exactly one of `USER`, `ROLE`, `SERVICE`, `ORGANIZATION`. Use `SERVICE` for an agent. | You | | `authority.principalId` | Must be your `callerId`, or another value in `allowedPrincipalIds`. | You | | `authority.issuedAt` | The current time as an ISO 8601 date time, for example `2026-09-20T10:00:00.000Z`. | You | | `authorization.authorizationId` | A new UUID. | You | | `authorization.authorityId` | **Equal to** `authority.authorityId`. | You | | `authorization.purpose` | A short sentence stating why the action is being requested. | You | | `authorization.issuedAt` | The current time, ISO 8601. | You | | `intent.intentId` | A new UUID. | You | | `intent.authorizationId` | **Equal to** `authorization.authorizationId`. | You | | `intent.action` | Your `ACTION`, exactly as in step 2. | You | | `intent.target` | The thing the action applies to, for example an order id. | You | | `intent.parameters` | An object with the action's parameters, for example `{"amount": 5}`. | You | | `intent.createdAt` | The current time, ISO 8601. | You | | `policy.name`, `policy.version` | The `policy` object from step 3, exactly as returned (`name`, `version`, `schemaVersion`). | You | | `policy.schemaVersion` | From step 3, with the name and version. | You | | `signals` | An object with one entry for every name in step 3's `signals.facts`. | You | | `status`, `createdAt` | Ignored if you send them. | The server | ### The consistency rules The server rejects a request that breaks rules 1 to 5 with HTTP `400` and a message that names the rule. A request that breaks rule 6 is treated as a policy rejection instead: HTTP `403` with code `POLICY_DENIED` and a message that contains `declared signal(s) do not match the executed intent`. 1. `metadata.businessTransactionId` equals `businessTransactionId`. 2. `authorization.authorityId` equals `authority.authorityId`. 3. `intent.authorizationId` equals `authorization.authorizationId`. 4. `policy.name`, `policy.version` and `intent.action` are not empty. 5. Every `businessTransactionId` is a valid UUID. 6. Where the policy binds a signal to an intent value (a `boundSignals` entry), the signal must equal that value exactly. For example the refund policy binds `signals.refundAmount` to `intent.parameters.amount`, so both must be the same number. ### A complete example This is a real request shape for the `customer-refund` policy. Replace every UUID with a new one, and the timestamps with the current time. ```json { "businessTransactionId": "5b1c0d2e-3f4a-4b5c-8d6e-7f8091a2b3c4", "metadata": { "businessTransactionId": "5b1c0d2e-3f4a-4b5c-8d6e-7f8091a2b3c4" }, "authority": { "authorityId": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", "authorityType": "SERVICE", "principalId": "my-agent", "issuedAt": "2026-09-20T10:00:00.000Z" }, "authorization": { "authorizationId": "9c8d7e6f-5a4b-4c3d-8e2f-1a0b9c8d7e6f", "authorityId": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", "purpose": "Refund a customer for a cancelled order", "issuedAt": "2026-09-20T10:00:00.000Z" }, "intent": { "intentId": "3d2c1b0a-9f8e-4d7c-8b6a-5f4e3d2c1b0a", "authorizationId": "9c8d7e6f-5a4b-4c3d-8e2f-1a0b9c8d7e6f", "action": "paytm:refund", "target": "ORDER-1001", "parameters": { "orderId": "ORDER-1001", "transactionId": "TXN-1001", "amount": 5 }, "createdAt": "2026-09-20T10:00:00.000Z" }, "policy": { "name": "customer-refund", "version": "1.2.0", "schemaVersion": "1.0.0" }, "signals": { "refundEligible": true, "managerApproved": false, "fraudCheckPassed": true, "refundAmount": 5 } } ``` Sent as it is, this example is refused with `403 POLICY_DENIED`, because `managerApproved` is `false` and every refund needs a signed manager approval. That refusal is the normal first step: it is what tells a person to approve. The version with the approval is below. **Never set a signal to `true` unless you have checked it is true.** A policy trusts the signals you send for some facts, so a false `true` produces an authorization that should not exist. **Approval signals are different.** For each signal in `APPROVAL_SIGNALS`, send `false` unless you hold a signed approval for this exact action. The server does not believe `true` on its own: it checks the signed approval, and refuses the request without one. Step 6 says how to get one. With a signed approval, send the signal as `true` and put the approval, exactly as you received it, in `signals.approvalArtifact`: ```json "signals": { "refundEligible": true, "fraudCheckPassed": true, "refundAmount": 5, "managerApproved": true, "approvalArtifact": { "payload": { "...": "..." }, "signature": { "...": "..." } } } ``` ## Step 5: send it **Do:** send `POST {PARMANA_URL}/execute` with the header, `Content-Type: application/json`, and the object from step 4. **Set a client timeout of at least 120 seconds.** The request runs the whole pipeline before it answers, including the call to the connector. In production an approved request took 24 to 32 seconds on 2026-10-01 (G-84), longer than the SDKs' default of 30 seconds: pass `timeout: 120_000` (TypeScript) or `timeout=120` (Python) to the client. **Never retry this request automatically.** A retry could repeat a real action. The rules for a timeout are in step 7. ## Step 6: read the answer Apply the **first** row that matches. Every error body is `{"error": ""}`, with a `code` when there is one. | You receive | Meaning | Do | | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | HTTP `200`, and `executions[0].decision.outcome` is `APPROVED` | Approved and released. You have a signed Execution Trust Record. | Go to step 7, then proceed. | | HTTP `403`, code `POLICY_DENIED`, and the message says the version was superseded | A newer policy version took effect after step 3. | Go back to step 3, then send again with a **new** `businessTransactionId`. | | HTTP `403`, code `POLICY_DENIED`, you sent an approval signal as `false`, and every other signal you sent is true | The action needs a person's signed approval. Nothing was executed. | Go to [If the action needs a person's approval](#if-the-action-needs-a-persons-approval). | | HTTP `403`, code `POLICY_DENIED` | A policy rule refused. Final. | Do not proceed. Do not retry. Report the message to the operator. | | HTTP `403`, code `CAPABILITY_NOT_ALLOWED` | Your key may not invoke this action. | Stop. Ask the operator to grant it. | | HTTP `403`, no code | Your key may not assert that `principalId`. | Use your `callerId`, or stop and ask. | | HTTP `401` | Key missing or wrong. | Stop. Ask for a valid key. | | HTTP `400` | The request is malformed. The message names the problem. | Fix the request. Use a **new** `businessTransactionId`. | | HTTP `404` | The policy was not found. | Stop. Go back to step 3. | | HTTP `409` | That `businessTransactionId` was already used. | Do not resend. Go to step 7 to look up what happened. | | HTTP `429` | Rate limited. Nothing was recorded. | Wait the seconds in the `Retry-After` header, then send again with a **new** `businessTransactionId`. | | HTTP `503`, code `SIGNING_UNAVAILABLE`, `EXECUTION_INTENT_UNAVAILABLE` or `AUDIT_UNAVAILABLE` | Refused before release. **Nothing was executed.** | Wait, then retry with a **new** `businessTransactionId`. If it keeps happening, tell the operator. | | HTTP `500`, code `EXECUTION_RECORD_INCOMPLETE` | The action **was** released, but its record could not be produced. | **Do not resend under any id.** Go to step 7. Tell the operator now. The operator can rebuild the signed record without repeating the action, see [Execution Intents](/concepts/execution-intents). | | HTTP `502`, code `EXECUTION_OUTCOME_UNKNOWN` | The action was released and the connector call failed. It **may** have been performed. | **Do not resend under any id.** Tell the operator, who checks the target system and closes the Execution Intent. | | Any other `5xx`, a timeout, or no response | Unknown. | Do not proceed. Go to step 7. | ## If the action needs a person's approval A refusal for want of an approval is not a failure. Parmana does not hold the request open; you send a new one once a person has approved. 1. **Tell the operator** what needs approving: the `ACTION`, the resource (for a refund, the order id), the amount if there is one, and your refused `businessTransactionId`. If the deployment has approval notifications turned on, the approver was already notified with the same details. 2. **Wait for a signed approval.** It is a JSON object with `payload` and `signature`, made by the approver on their own machine. You cannot make one, and you must never ask for or hold an approver's private key. 3. **Check it matches before sending.** `payload.capability` equals `ACTION`, `payload.resourceId` equals the resource, and `payload.expiresAt` is in the future. An approval is usually valid for 15 minutes and works once. 4. **Send a new request** (step 3 again, then step 4) with a **new** `businessTransactionId`, the same action and amount, the approval signal `true`, and the approval in `signals.approvalArtifact`. Do not change anything else. 5. If it is refused again, do not retry with the same approval: it may be expired, used, for a different resource or amount, or from an approver the server does not trust. Report the message to the operator. **In the sandbox only** (`https://parmana-sandbox.vercel.app`, `ACTION` `sandbox:receipt`), steps 1 and 2 are replaced by one request you make yourself: `POST /sandbox/approvals` with `{ "capability": "sandbox:receipt", "resourceId": "" }`. Expect `201` and an approval valid 5 minutes, accepted once. Then do steps 3 and 4. This route exists only in the sandbox; on any other server, never try to make an approval yourself. See [Playground](/playground). For people: [Human approval](/concepts/human-approval) and [Approval notifications](/guides/approval-notifications). ## Step 7: confirm and keep the record **After an approval (HTTP `200`),** confirm all of these in the response, then store the whole response: 1. `executions[0].decision.outcome` is `APPROVED`. 2. `verifications[0].status` is `VERIFIED`. 3. `receipts[0]` exists. If any is missing, treat the action as **not confirmed** and tell the operator. Store the response and the `businessTransactionId` together. They are your proof. **After a `409`, a timeout, no response, `EXECUTION_RECORD_INCOMPLETE` or `EXECUTION_OUTCOME_UNKNOWN`,** find out what the server recorded. Send `GET {PARMANA_URL}/trust-records/{businessTransactionId}` with the header. | The lookup returns | Meaning | Do | | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | HTTP `200` and a record | The request was processed. | Apply step 6 to the record: check `decision.outcome`, `verifications[0].status` and `receipts[0]`. | | HTTP `404` on the record, and `GET {PARMANA_URL}/transactions/{businessTransactionId}` returns `200` | The server accepted it but has no record. It may still be running, or it failed. | Wait 30 seconds and look up once more. If still no record, **stop and tell the operator.** Do not resend. | | HTTP `404` on both | The server never recorded it. | It is safe to send again with a **new** `businessTransactionId`. | ## Rules that are never broken 1. Never reuse a `businessTransactionId`. 2. Never resend after `EXECUTION_RECORD_INCOMPLETE` or `EXECUTION_OUTCOME_UNKNOWN`, or after a timeout you have not resolved with step 7. 3. Never treat "I could not reach Parmana" as "allowed". 4. Never work around a `POLICY_DENIED` by changing the request until it passes. A denial is a decision. Tell the operator. The two exceptions are in step 6: a superseded version, and a missing approval, which a person resolves, not you. 5. Never send a signal you have not verified. 6. Never log the API key. ## Where the machine readable sources are | Source | URL | Use it for | | ----------------------- | ----------------------------------------------- | ------------------------------------------------------------------------ | | OpenAPI, JSON | `{PARMANA_URL}/openapi.json` | Exact request and response schemas for every endpoint. | | OpenAPI, YAML | `{PARMANA_URL}/openapi.yaml` | The same, as YAML. | | API manifest | `{PARMANA_URL}/api-manifest.json` | The API version, the authentication scheme and the current SDK versions. | | Docs index for agents | `https://docs.parmanasystems.com/llms.txt` | A list of every documentation page with a one line description. | | Docs in full for agents | `https://docs.parmanasystems.com/llms-full.txt` | The integration pages concatenated into one file. | If you use an SDK instead of raw requests, the steps and the rules are the same. The [TypeScript SDK](/sdks/typescript) and the [Python SDK](/sdks/python) both offer a helper that builds the Business Transaction and keeps the identifiers consistent. Neither retries `POST /execute` for you. For a human reading this page, the same material with explanations is in [Full integration overview](/guides/full-integration-overview) and the [Error catalog](/api-reference/error-catalog). --- ## Deploy Parmana: specification for AI agents Source: https://docs.parmanasystems.com/agents/deploy This page is written to be followed literally by an AI agent or a person. It contains no options to weigh. If your situation is not covered, **stop and ask the operator**. Do not guess. The human readable version, with more explanation, is the [Production deployment runbook](/deployment/production). Every variable is defined in the [Environment variable reference](/deployment/environment-variables). If the two disagree with this page, the environment reference wins, and you must tell the operator. ## Rules that apply to every step 1. Do the steps in order. Do not skip one. 2. After each step compare what you saw with **Expect**. If it differs, follow **If not** and stop. 3. Never print, log, commit or paste a secret. Secrets are: raw API keys, bearer keys, private key files, `DATABASE_URL`, `PARMANA_KEY_MATERIAL_JSON`, connector secrets and tokens. 4. Never set `PARMANA_AUTH_DISABLED`, and never set `NODE_ENV` to `test` or `development` on a production server. 5. Never deploy to production, write production environment variables or publish anything unless the operator has told you to in this session. Preparing the values and the commands is always allowed. 6. The maker and the checker in step 8 must be two different human identities, and both keys must have `credentialHolderType` `USER`. You act as the maker only if the operator gave you the maker's key and said so. You must never approve your own proposal. The checker is a human. ## Inputs you need before you start | Input | Where it comes from | How to check it | | -------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | `REPO` | A clone of the Parmana repository. | The file `package.json` exists at its root. | | `DATABASE_URL` | The operator. A Postgres connection string. | It starts with `postgresql://`. On a serverless host it is the pooler string. | | `HOST_KIND` | The operator: `fly`, `vercel` or `docker`. | It is exactly one of those three words. | | `URL` | The operator, or the host after deploy. The base URL, no trailing slash. | It starts with `https://`. | | `FIRST_CALLER_ID` | The operator. The name of the first caller, for example `my-agent`. | It is non empty and has no spaces. | | `FIRST_CAPABILITIES` | The operator. The actions that caller may use, for example `paytm:refund`. | It is non empty. Never use `*` unless the operator says so in words. | | `MAKER_ID` | The operator. The name of the human maker. | It differs from `FIRST_CALLER_ID` and from `CHECKER_ID`. | | `CHECKER_ID` | The operator. The name of the human checker. | It differs from `FIRST_CALLER_ID` and from `MAKER_ID`. | If any input is missing, stop and ask for it. ## Step 1: Generate the signing keys Run from `REPO`: ```bash npx tsx scripts/generate-keypair.ts --algorithm ed25519 --key-id default npm run generate:gateway-keys ``` **Expect:** the directory `keys` holds `default.private.pem`, `default.public.pem`, `gateway.private.pem` and `gateway.public.pem`. **If not:** a command reports that a file already exists. Do not add `--force`. Stop and ask the operator whether those keys are the ones in use. ## Step 2: Create the database schema Ask the operator to apply the schema, or apply it if you have Supabase CLI access. There is exactly one accepted method for an agent: run `supabase db push` in a directory linked to the project. Otherwise give the operator the file `scripts/apply-all-migrations.sql` and ask them to run it in the Supabase SQL Editor. **Expect:** the query `select count(*) from consumed_nonces;` returns a number. **If not:** the query says the table does not exist. The schema is not applied. Stop and report it. If a later request says `PGRST205`, run `NOTIFY pgrst, 'reload schema';` and try once more. ## Step 3: Generate the caller keys ```bash npm run generate:api-key -- --caller-id FIRST_CALLER_ID --allowed-capabilities FIRST_CAPABILITIES npm run generate:api-key -- --caller-id MAKER_ID --credential-holder-type USER npm run generate:api-key -- --caller-id CHECKER_ID --credential-holder-type USER --generate-step-up-key ``` Replace the capitalised words with the input values. Each command prints a raw key, and a JSON entry. The third also prints a step up private key. **Expect:** three JSON entries, each with `callerId` and a `keyHash` of 64 lowercase hexadecimal characters. The second and third have `credentialHolderType` set to `USER`, and the third has a `stepUpPublicKey`. **If not:** stop and report the output, with any key values removed. Do the following with the printed values, and nothing else: 1. Hand the first raw key to the caller through the channel the operator names. 2. Hand the maker bearer key to the maker, and the checker bearer key and the step up private key to the checker, and to nobody else. 3. Keep the three JSON entries. Join them into one array. That array is `PARMANA_API_KEYS`. ## Step 4: Set the environment on the host Set exactly these variables on the host, in the Production environment: | Variable | Value | | -------------------- | ------------------------ | | `NODE_ENV` | `production` | | `PARMANA_POLICY_DIR` | `./policies` | | `PARMANA_API_KEYS` | The array from step 3 | | `PARMANA_STORAGE` | `supabase` | | `DATABASE_URL` | The input `DATABASE_URL` | | `PARMANA_KEY_DIR` | `./keys` | If the host cannot mount the four key files, also set `PARMANA_KEY_MATERIAL_JSON` to `{"default":{"privateKeyPem":"","publicKeyPem":""},"gateway":{"privateKeyPem":"","publicKeyPem":""}}`, with the file contents from step 1. If the operator wants a connector, also set its variables from the [connector section](/deployment/environment-variables#connectors). For the refund connector set both `PAYTM_CONNECTOR_URL` (it must start with `https://`) and `PAYTM_CONNECTOR_SHARED_SECRET`, or neither. **Expect:** the host lists all six variables, and does not list `PARMANA_AUTH_DISABLED`. **If not:** stop and report which variable is wrong. Never repeat a secret value in your report. Rule 5 applies. If the operator has not told you to write to the host, print the variable names and stop, and let the operator set the values. ## Step 5: Deploy | `HOST_KIND` | Command | | ----------- | ---------------------------------------------------------------------------------------------- | | `fly` | `fly deploy` | | `vercel` | `vercel deploy --prod` | | `docker` | `docker build -t parmana-api .` then run the image with the variables from step 4, port `3000` | Environment variables are read once at startup, so a change to any of them needs a new deploy. **Expect:** the deploy finishes and the host shows the new version live. **If not:** read the host's log. A startup failure names the variable it dislikes. Look the message up in the [startup errors table](/deployment/environment-variables#startup-errors-and-what-to-do), fix that one thing and deploy again. Do not change anything else. ## Step 6: Check the server Replace `URL` with the input value. ```bash curl -s "URL/health" curl -s "URL/ready" curl -s -o /dev/null -w "%{http_code}" -X POST "URL/execute" -H "Content-Type: application/json" -d "{}" ``` **Expect:** | Request | Answer | | ----------------------- | ---------------------------------------------------- | | `GET /health` | `200` and `"status":"UP"` | | `GET /ready` | `200`, `"status":"READY"` and `"authDisabled":false` | | `POST /execute`, no key | The three digit code `401` | **If not:** | You saw | Meaning | Do | | ------------------------------------------ | ------------------------------------- | -------------------------------------------------------------------------------- | | `/ready` returns `503` `NOT_READY` | The database is unreachable. | Check `DATABASE_URL` and the network path. Stop and report. | | `"authDisabled":true` | Authentication is off. | Remove `PARMANA_AUTH_DISABLED` and deploy again at once, then tell the operator. | | `POST /execute` returns anything but `401` | Caller authentication is not working. | Stop. Do not send any real request. Report it. | ## Step 7: Copy the policies into the database Run from `REPO`, with `DATABASE_URL` and `PARMANA_POLICY_DIR=./policies` set in your shell: ```bash npx tsx scripts/apply-policies-table-and-backfill.ts ``` **Expect:** one line per policy, then `Backfilled N policies into the 'policies' table.` **If not:** stop and report the error message. ## Step 8: Approve each policy For every policy the operator lists, in the form `POLICY_NAME` and `POLICY_VERSION`: 1. You never hold the checker's keys. You act as the maker only if the operator gave you a maker's bearer key and said so. Otherwise ask the operator to propose. 2. Propose the change as the maker, following the [Policy governance guide](/guides/policy-governance-maker-checker), step 1. The response has status `201` and a `pendingPolicyChangeId`. 3. Tell the checker the `pendingPolicyChangeId` and ask them to review it and approve it with their own key, as in steps 2 to 4 of that guide, or with `scripts/local-review-action.ts`. 4. Wait until the checker confirms. **Expect:** `GET URL/policies/pending-changes?status=PENDING_APPROVAL` no longer lists your `pendingPolicyChangeId`. **If not:** | You saw | Meaning | Do | | ---------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------ | | The approve call returns `403` | The checker key is not `USER`, or the maker and the checker are the same. | Stop and tell the operator. Never approve it yourself. | | The approve call fails after 120 seconds | The step up signature expired. | Ask the checker to sign and submit again, in one step. | Do not run `scripts/backfill-legacy-policy-approvals.ts`. It records a system approval that is not a human decision. Only the operator may choose that, in words. Then run the check. Set `SUPABASE_URL` and `SUPABASE_ANON_KEY` in your shell first. They are for this script only: ```bash npx tsx scripts/verify-policy-changes-approved.ts --full-scan ``` **Expect:** the script exits with code `0`. **If not:** it lists each live policy without a matching approval. Approve those, and run the check again. ## Step 9: Send one real request Follow [Integrate Parmana: specification for AI agents](/agents/integrate) with a small, harmless action that the policy allows. The action needs a configured connector from step 4. **Expect:** the response is a signed Execution Trust Record. Sending the same `businessTransactionId` again returns `409`. **If not:** a `503` with code `CONNECTOR_NOT_REGISTERED` means the connector is not configured. Nothing was executed. Configure it as in step 4 and deploy again. For anything else use the table in that page. Never send the same action again under a new id after a `500` with the code `EXECUTION_RECORD_INCOMPLETE`. That action was released. Tell the operator. ## Step 10: Report Give the operator one message with: 1. The base URL and the answers from step 6. 2. The number of policies approved and the result of the check in step 8. 3. The result of step 9. 4. Anything you skipped, and why. Do not include any secret in the message. ## When to stop and ask Stop and ask the operator when: 1. An input is missing, or two inputs conflict. 2. A command tells you a file already exists, and you were not told to overwrite it. 3. You are asked to deploy, write production variables or publish, and you were not told to. 4. Anything in **Expect** differs and its **If not** row says to stop. 5. You would have to guess a value, a name or a person. --- ## Quickstart Source: https://docs.parmanasystems.com/quickstart ## Step 0: try it live, with nothing installed Before you install anything, call the public sandbox. It is a live Parmana server with a published demo key, and its one action, `sandbox:receipt`, acts on nothing. These two requests show the core of Parmana in one minute: the first is refused because it has no signed human approval, and the [Playground](/playground) then walks you through getting one, sending again, and verifying the signed record. ```bash cURL export PARMANA_API_KEY=2VfYWCzt_cBAPK-8uufX6ordfY2JuQhFPsohuEumKME # the published sandbox demo key curl -s https://parmana-sandbox.vercel.app/callers/me -H "Authorization: Bearer $PARMANA_API_KEY" curl -s "https://parmana-sandbox.vercel.app/policies/in-effect?capability=sandbox:receipt" -H "Authorization: Bearer $PARMANA_API_KEY" ``` ```powershell PowerShell $env:PARMANA_API_KEY = "2VfYWCzt_cBAPK-8uufX6ordfY2JuQhFPsohuEumKME" # the published sandbox demo key $Headers = @{ Authorization = "Bearer $env:PARMANA_API_KEY" } Invoke-RestMethod https://parmana-sandbox.vercel.app/callers/me -Headers $Headers Invoke-RestMethod "https://parmana-sandbox.vercel.app/policies/in-effect?capability=sandbox:receipt" -Headers $Headers ``` The first answers `"callerId": "sandbox-visitor"`, allowed only `sandbox:receipt`. The second names the policy in effect, `sandbox-receipt` 1.0.0, and the facts a request must carry. Continue on the [Playground](/playground), or go on below to run the same thing on your own machine, where you play the approver too. ## Run it on your own machine This walkthrough uses `test:fixture-execute`, a generic, credential-free capability built for local testing. It's evaluated against a policy named `vendor-payment`. That policy name is unrelated to any connector; it's simply the example policy this transaction runs against. `SignalIntentBinder` rejects a transaction whose declared `vendorId` signal doesn't exactly match its `intent.target` (see [Policies and the decision](/concepts/policies-and-the-decision)), so the execute step below sets both to the same value. Code samples below are shown for Python and TypeScript. Pick whichever matches your client. No AI agent action is authorized without a signed human approval, reads included ([Human approval](/concepts/human-approval)). So this quickstart has you play the approver too: you make an approver key, the local server trusts it, and you sign an approval before the agent's request. On a real deployment the approver is a different person, added through [Manage approvers](/guides/manage-approvers). Running Parmana locally to evaluate it is permitted; any other use needs a written agreement, see [License and evaluation](/license). ```bash cp .env.example .env npm ci npm run build ``` Only `.env.example` is in the repository; `.env` is gitignored and holds your local settings. The server signs authorizations and records with the `default` key, and the Execution Gateway signs its own attestations with a separate `gateway` key. `keys/` is gitignored, so a fresh clone has neither. Create both: ```bash npx tsx scripts/generate-keypair.ts --algorithm ed25519 --key-id default npm run generate:gateway-keys ``` This writes `keys/default.private.pem`, `keys/default.public.pem`, `keys/gateway.private.pem` and `keys/gateway.public.pem`. They stay local. See [Deploy patterns](/guides/deploy-patterns) for how to manage keys outside local dev. ```bash npx tsx scripts/generate-approver-key.ts \ --approver-id local-test-approver --key-id local-test-approver-key-1 \ --out-dir ~/.parmana ``` This writes `~/.parmana/local-test-approver__local-test-approver-key-1.private.pem` (keep it) and `.public.pem` (the server trusts it in the next step). The `.env` copied from `.env.example` defaults to Supabase-backed storage. To run fully locally with no external dependency, override storage to `memory` **and** set `NODE_ENV=test`. Both are required: `PARMANA_STORAGE=memory` alone only affects where Business Transactions and Trust Records are stored, two other components, the Execution Gateway's replay-nonce store and the caller-authentication audit trail, independently default to Supabase outside `NODE_ENV=test`. With `NODE_ENV=test`, a generic, test-only connector (`test-fixture`) registers automatically, no credential environment variable required, and the server trusts `local-test-approver` with the key in `PARMANA_TEST_APPROVER_PUBLIC_KEY_FILE`: ```bash NODE_ENV=test \ PARMANA_STORAGE=memory \ PARMANA_API_KEYS='[{"callerId":"demo","keyHash":"325ededd6c3b9988f623c7f964abb9b016b76b0f8b3474df0f7d7c23b941381f","allowedCapabilities":["test:fixture-execute"]}]' \ PARMANA_TEST_APPROVER_PUBLIC_KEY_FILE=~/.parmana/local-test-approver__local-test-approver-key-1.public.pem \ npm run dev ``` `PARMANA_TEST_APPROVER_PUBLIC_KEY_FILE` is honored only when `NODE_ENV` is `test`. With any other `NODE_ENV` the server refuses to start while it is set. Confirm it's up: ```bash curl http://localhost:3000/health # {"status":"UP"} ``` The Execution Gateway is wired into this server unconditionally. Every `POST /execute` is independently re-verified and routed through a real Connector. An action with no registered connector fails closed with `"No connector registered for action"`, it does not silently skip enforcement. See [The gateway](/concepts/the-gateway). `/health` is the one route exempt from caller authentication (along with `/ready`, `/openapi.yaml`, `/openapi.json`, `/api-manifest.json`, `/documentation`, and `/reference`). Everything else, including `/version`, fails closed with a `401` before a Business Transaction is even constructed: ```bash curl http://localhost:3000/version # {"error":"authentication required"} ``` The `PARMANA_API_KEYS` entry in the previous step is a demo caller key for local development only: `callerId: "demo"`, raw key `my-secret-api-key`, allowed to use `test:fixture-execute` and to act as `demo` (only its SHA-256 hash is stored, see [Authentication](/api-reference/authentication)). Its raw key is public, so never use it on a server anyone else can reach; make real keys with `npm run generate:api-key`. Send it as a bearer token and the same route succeeds: ```bash curl http://localhost:3000/version \ -H "Authorization: Bearer my-secret-api-key" # {"name":"Parmana","version":"0.4.0","api":"v1"} ``` This is real, fail-closed authentication, not a placeholder, see [Authentication](/api-reference/authentication) for how keys are minted and rotated outside this demo key. ```bash Python pip install parmana ``` ```bash TypeScript npm install @parmana/sdk ``` The SDK sends the bearer key from the previous step on every request: ```python Python from parmana import ParmanaClient client = ParmanaClient(endpoint="http://localhost:3000", api_key="my-secret-api-key") ``` ```typescript TypeScript import { ParmanaClient, HttpTransport } from "@parmana/sdk"; const endpoint = "http://localhost:3000"; const apiKey = "my-secret-api-key"; const client = new ParmanaClient({ endpoint, apiKey, transport: new HttpTransport({ endpoint, apiKey }), }); ``` First the approver signs an approval for this action, this target, up to this amount. Then the agent sends the transaction with the approval attached. The `createBusinessTransaction` helper derives every id pair the server checks for consistency, so there's nothing to hand-assemble and get wrong: ```python Python from pathlib import Path from parmana import ParmanaClient, PolicyReference, create_business_transaction from parmana.crypto import sign_approval client = ParmanaClient(endpoint="http://localhost:3000", api_key="my-secret-api-key") # The approver's step, on the approver's machine. approval = sign_approval( private_key_pem=Path.home().joinpath( ".parmana/local-test-approver__local-test-approver-key-1.private.pem" ).read_text(), approver_id="local-test-approver", key_id="local-test-approver-key-1", capability="test:fixture-execute", resource_id="vendor://payments", max_amount=1000, ) transaction = create_business_transaction( principal_id="demo", purpose="Quickstart demo", action="test:fixture-execute", target="vendor://payments", parameters={"amount": 1000, "currency": "USD"}, policy=PolicyReference(name="vendor-payment", version="2.1.0", schema_version="1.0.0"), signals={ "humanApproved": True, "approvalArtifact": approval, "vendorVerified": True, "invoiceVerified": True, "paymentApproved": True, "sufficientFunds": True, "paymentAmount": 1000, "riskScore": 5, "vendorId": "vendor://payments", }, ) trust_record = client.execution.execute(transaction) print(trust_record.trust_record_hash) ``` ```typescript TypeScript import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { ParmanaClient, HttpTransport, createBusinessTransaction, signApproval, ExecutionRejectedError, } from "@parmana/sdk"; const endpoint = "http://localhost:3000"; const apiKey = "my-secret-api-key"; const client = new ParmanaClient({ endpoint, apiKey, transport: new HttpTransport({ endpoint, apiKey }), }); // The approver's step, on the approver's machine. const approval = signApproval({ privateKeyPem: readFileSync( join(homedir(), ".parmana/local-test-approver__local-test-approver-key-1.private.pem"), "utf8", ), approverId: "local-test-approver", keyId: "local-test-approver-key-1", capability: "test:fixture-execute", resourceId: "vendor://payments", maxAmount: 1000, }); const transaction = createBusinessTransaction({ principalId: "demo", // the key may act only as its own caller id purpose: "Quickstart demo", action: "test:fixture-execute", target: "vendor://payments", parameters: { amount: 1000, currency: "USD" }, policy: { name: "vendor-payment", version: "2.1.0", schemaVersion: "1.0.0" }, signals: { humanApproved: true, approvalArtifact: approval, vendorVerified: true, invoiceVerified: true, paymentApproved: true, sufficientFunds: true, paymentAmount: 1000, riskScore: 5, vendorId: "vendor://payments", // must exactly equal `target` }, }); async function main() { try { const trustRecord = await client.execute(transaction); console.log("Trust Record ID:", trustRecord.trustRecordId); } catch (error) { if (error instanceof ExecutionRejectedError) { console.error("Rejected:", error.message); process.exitCode = 1; return; } throw error; } } main(); ``` Without `humanApproved` and a valid `approvalArtifact`, the server refuses this transaction with `403 POLICY_DENIED`, whatever else the signals say. Full runnable versions: `python/examples/quickstart/run.py` and `typescript/examples/06-create-business-transaction.ts`, the same transactions run against a test server with authentication off, so they act as other principals; each takes the signed approval as an argument, or from the file `PARMANA_APPROVAL_FILE` names when run as a script. `python/tests/test_quickstart_example.py` and `typescript/test/integration/examples.integration.test.ts` run them against a real, freshly-spawned server on every test run, so both examples are verified to work, not just syntax-checked. The two samples above were run as shown, with the demo key, against a server started as in this page, on a fresh clone on 2026-10-01. Captured from an actual run against a local server, 2026-09-14: ``` Trust Record Hash: a08a041317a6f28307a30c97147ef320e896578153118e978f7998a63cf52037 ``` The full `ExecutionTrustRecord` includes the `signature` block, `executions[0].decision` (`outcome: "APPROVED"`, evaluated by the `vendor-payment` policy), `executions[0].evidence` (what the connector actually did, including a `connectorEvidenceHash`), and an initial `verifications` / `receipts` history. See [Trust Record](/concepts/execution-trust-records) for the complete shape, or `python/examples/quickstart/README.md` for the full captured JSON. For the equivalent captured TypeScript output (`camelCase` field names, same record shape), see [TypeScript SDK Quickstart](/guides/typescript-sdk-quickstart)'s own "Real output" step. ## Next The full flow against the live sandbox: refusal, signed approval, approved release, offline verification. The concepts behind what just happened: policy, authorization, the gateway, trust records. Read back or re-run verification against the record you just created. The same flow against a real connector and real infrastructure, not the local test fixture. --- ## Build with Parmana Source: https://docs.parmanasystems.com/build-book/overview This book takes you from nothing to a production integration: an AI agent that can act on a real system only when a policy and a person allow it, with a signed record of every decision. It is the companion to [The Parmana Handbook](/handbook/overview), which explains how each part of Parmana is built. This book explains how to **build with** it. Download it as one PDF: [build-with-parmana.pdf](/build-with-parmana.pdf). ## Who it is for | You are | Start with | | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | A developer connecting an agent | [Chapter 1](/build-book/01-how-parmana-works), then [Chapter 3](/build-book/03-connect-an-agent) | | An operator bringing Parmana to production | [Chapter 8](/build-book/08-deploy-and-operate), then [Chapter 4](/build-book/04-policies) and [Chapter 5](/build-book/05-human-approvals) | | Connecting a system Parmana has no code for | [Chapter 6](/build-book/06-connect-your-systems) | | Reviewing Parmana for security | [Chapter 10](/build-book/10-security-model-and-limits), then [Chapter 7](/build-book/07-verify-and-audit) | | An AI agent | [Integrate Parmana: specification for AI agents](/agents/integrate), and `llms-full.txt` for this site in one file | ## How the book is written - **Every statement is checked against the code.** Where the book and the code disagree, the code wins and the book is fixed. Anything not verified in production says so. - **The reference chapters are generated from the code.** [Every endpoint](/build-book/reference-endpoints), [every SDK method](/build-book/reference-sdk) and [every error code](/build-book/reference-errors) are produced by `scripts/generate-build-book-reference.ts` from the OpenAPI file, the SDK source and the server's error classes. A test fails when they fall behind the code. - **Examples are real.** Responses are captured from the running server; tutorial output is what the tutorial prints. - **Procedures state what to expect.** Where a step can fail, it says what you see and what to do. ## The chapters 1. [How Parmana works](/build-book/01-how-parmana-works): one request, from an agent to a signed record. 2. [Your first governed action](/build-book/02-your-first-governed-action): run it on your machine, step by step. 3. [Connect an agent](/build-book/03-connect-an-agent): the request, the policy in effect, approvals, in both SDKs. 4. [Policies](/build-book/04-policies): write one, get it approved, change it, roll it back. 5. [Human approvals](/build-book/05-human-approvals): approvers, signing, and what an approval covers. 6. [Connect your systems](/build-book/06-connect-your-systems): built in connectors and external connectors. 7. [Verify and audit](/build-book/07-verify-and-audit): records you can check without trusting the server. 8. [Deploy and operate](/build-book/08-deploy-and-operate): production, keys, migrations, monitoring. 9. [Errors and troubleshooting](/build-book/09-errors-and-troubleshooting): what each answer means and what to do. 10. [Security model and limits](/build-book/10-security-model-and-limits): what is guaranteed, and what is not. Reference: [every endpoint](/build-book/reference-endpoints), [every SDK method](/build-book/reference-sdk), [every error code](/build-book/reference-errors). ## Versions This edition describes the code on `main` as of 2026-10-01. The published SDKs are 1.6.0 (`@parmana/sdk` on npm, `parmana` on PyPI), published 2026-10-11. Methods marked **1.5.0** need that version or later: `policyInEffect` and `policy_in_effect`, `verifyParmanaRelease` and `verify_parmana_release`. --- ## Full Integration Overview Source: https://docs.parmanasystems.com/guides/full-integration-overview This page is a map, not a replacement for the guides it links to. For the files a new connector needs, see `docs/connectors/BUILDING_A_CONNECTOR.md` in the repository. **A naming trap worth avoiding first:** this codebase has two different things called "connector." [`@parmana/connector-sdk`](/integrations/overview) is a generic library with reference mocks (`SapConnector`, `OracleConnector`, etc.) that never talk to a real system. The connectors the default server actually registers and runs in production, HubSpot, GitHub, Paytm and Slack, follow a different, simpler pattern documented in the [Connector Development Guide](/integrations/connector-development-guide). Any other HTTPS endpoint can be connected with no code at all, as an **external connector**: see [Connect any external system](/guides/connect-any-external-system). This page covers both real paths. ## The four pieces | # | Piece | Answers | Exists independently of the others? | | --- | ------------------------------------------ | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | **A policy** bound to a capability | Should this request be allowed? | Yes. It decides with no connector or caller wired to it. Every approve rule needs a signed human approval. | | 2 | **A caller (agent)** with a scoped API key | Who's allowed to even ask? | Yes. It gets real decisions with no connector registered. | | 3 | **A connector** for the capability | Who performs the real world action once approved? | Yes. A built in one is testable alone with `MockConnector`; an external one is your own endpoint. | | 4 | **A `SignalStateVerifier`** | Are the caller's claims about real world state actually true? | The human approval is always verified this way. Other facts are checked only where a verifier exists (HubSpot deal state, the Slack channel). | They're deliberately decoupled. That's why "got `APPROVED`" and "the action actually happened" are two separate, independently verifiable claims (see [Connect an Agent](/guides/connect-an-agent), Step 8). ## Recommended build order 1. **Write the policy first**, see [Write Your First Policy](/guides/write-your-first-policy). Everything else needs the exact capability string and `signalsSchema` up front. 2. **Onboard a caller and test authorization with no connector yet**, see [Connect an Agent](/guides/connect-an-agent). A `503 CONNECTOR_NOT_REGISTERED` at this stage is expected: the policy approved, and nothing was executed. 3. **Connect the target system:** register your endpoint as an external connector ([Connect any external system](/guides/connect-any-external-system)), or build a built in connector ([Connector Development Guide](/integrations/connector-development-guide)). 4. **Add a `SignalStateVerifier` only if a caller lying about a signal is a real risk** for this capability that a policy rule alone can't catch. Built in connectors only. ## What breaks if you skip a piece Every row here is a real failure mode this codebase has actually hit and diagnosed, several the same night integrating a real refund agent partner. | You have | Missing | What happens | | --------------------------------------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | Policy + caller key | Connector | Approved, then `503 CONNECTOR_NOT_REGISTERED` before release. Nothing was executed. | | Connector + caller key | Policy | `404` (`Policy '' version '' was not found.`). Never reaches your connector. | | Policy + connector | Caller key or scoping | `401`, or `403 CAPABILITY_NOT_ALLOWED`, or `403` (principal not permitted). | | All three | `SignalStateVerifier` | Works, but a caller's false claim about an unverified fact is trusted. A named, accepted scope limit; the signed human approval still has to be real. | | Everything, but the deployment's signing config is broken | Nothing | `503 SIGNING_UNAVAILABLE` before release. An operator problem none of the four pieces can fix from outside. | ## Next Author the policy that decides APPROVE/REJECT for your capability. Onboard a caller with a scoped API key and test authorization end to end. Implement and register the connector that performs the real action. How a connector receives a credential it never fetches itself. --- ## Authorize and Execute an Action End to End Source: https://docs.parmanasystems.com/guides/authorize-and-execute **[AVAILABLE]**, every command below runs against the repo's actual code. ## Goal See every stage of [how Parmana thinks](/how-parmana-thinks) fire for one real transaction: policy decision, the signed authorization envelope itself (not just its effects), gateway re-verification, and the resulting trust record. ## Prerequisites - Completed [Quickstart](/quickstart) once, or at least read it, this guide assumes the server and keys are already set up the same way. - Read [Execution authorization](/concepts/execution-authorization), this guide shows the envelope that page describes. ## Steps ### 1. See the authorization envelope directly Over HTTP, the envelope comes back inside the Trust Record, in its `authorization` field, after [the gateway](/concepts/the-gateway) has verified and consumed it. To see it produced step by step, use the Runtime library directly, which is what `examples/tutorials/11-execution-authorization/run.ts` does: build a `Runtime` with a `FilePolicyRepository`, execute a transaction, and print `result.context.authorization`. ```bash node_modules/.bin/tsx examples/tutorials/11-execution-authorization/run.ts ``` Real output (run again on 2026-10-02): ``` Authorization Summary Payload Version : 1 Authorization ID : 30c915ca-4516-459f-aae3-b501b40cd2f1 Decision ID : 8e04698b-d56c-4974-a8be-57903907058f Transaction ID : txn-000001 Policy : vendor-payment@2.1.0 Algorithm : ed25519 Key ID : default Authorized At : 2026-10-02T10:35:53.219Z Expires At : 2026-10-02T10:37:53.219Z Nonce : 888534f5-6bb0-43b4-b32c-270f26c58bf1 Content Hash : 69b1858cd12ceeee715c5d90e22e68e1f1466a7047c3c7fdbb8056a434b5ec93 ``` `Content Hash` is `businessTransactionHash`, the field [The gateway](/concepts/the-gateway) independently recomputes and compares before releasing execution. `Expires At` is two minutes after `Authorized At`, the envelope's TTL. ### 2. Run the same shape of request through the full server This library call skips the gateway and connector entirely, it's the Runtime in isolation. To see the _whole_ pipeline, authorization through gateway verification through credential issuance through a connector, run [Quickstart](/quickstart) steps 3 and 5 if you haven't: start the server, then execute a transaction over HTTP. ```bash PARMANA_STORAGE=memory PARMANA_POLICY_DIR=/absolute/path/to/policies NODE_ENV=test npm run dev ``` ```python trust_record = client.execution.execute(transaction) ``` ## Verify Compare what each path returns. The library call above hands you the `SignedExecutionAuthorization` on its own. The HTTP call hands you the final `ExecutionTrustRecord`, which carries the same envelope in `authorization` (with its `payload`: `authorizationId`, `nonce`, `policyContentHash`, `signalsHash`, `expiresAt`, `businessTransactionHash` and the rest) and covers it with the record's signature. Both paths produce a record whose `executions[0].decision.outcome` is `"APPROVED"` for the same input signals and policy. ## Troubleshoot - **`Execution Authorization was not generated` thrown by the tutorial.** The transaction's Decision came back `REJECTED`, an authorization is signed only after approval, see [Write your first policy](/guides/write-your-first-policy) for what a rejected decision actually does (it throws, it doesn't return a `REJECTED` object). - **The HTTP path's trust record has no `authorization` field.** Records created before the field was added have none; every new approved record has it. - **`Content Hash` differs between two runs with identical-looking transaction JSON.** Check timestamps and generated IDs (`businessTransactionId`, `intentId`, etc.), the hash covers the full executable content, any field that differs between runs changes it. ## Next Mutate this same content and watch the hash comparison reject it. Check the resulting trust record's signature without Parmana running at all. --- ## Human approval Source: https://docs.parmanasystems.com/concepts/human-approval **No AI agent action is authorized without a signed human approval. Reads included.** A refund, a merge, a Slack post, a HubSpot update, reading a pull request or a deal: each runs only when a trusted person has signed an approval for that action, on that resource, and the approval has not expired or been used. People also approve policies before those policies can authorize anything, and review every refused request. Parmana does not hold a request while it waits for a person: a refused request stays refused, and the agent sends a new one with the approval attached. | What a person does | Status | | --------------------------------------------------- | -------------------------------------- | | Sign an approval for every agent action, reads too | **[REQUIRED]** | | Approve a policy before it can authorize anything | **[AVAILABLE]** | | Review refused requests | **[AVAILABLE]** | | Get notified when a request waits for an approval | **[AVAILABLE]**, when a webhook is set | | Add or revoke an approver without a deploy | **[AVAILABLE]** | | Approve a refused request so that same request runs | **[NOT AVAILABLE]** | ## Every action needs a signed approval Two checks enforce this in code, for every policy and every action: - **When a policy loads.** Every rule that approves must require one of the policy's `approvalSignals` with `is_true`, either as its whole condition or directly inside its top level `all`. A policy with any other approve rule fails to load, so every request under it is refused with `400` and `approves without a signed human approval`. A proposed policy change of that shape is refused the same way, so it cannot get through policy governance either. - **When a request is decided.** A signal in `approvalSignals` counts as true only with a valid signed approval in the request: from a trusted, unrevoked approver, for this action and this resource (and amount, where the policy names one), not expired, used once. A runtime with no approval verifier configured refuses every approval instead of trusting the signal. An agent that sets the approval signal to `true` without a signed approval is refused, whatever else it declares. Tutorials 119 to 122 in `examples/tutorials/` show it step by step, for a refund, the rule itself, a read, a Slack post and a HubSpot update. | Action | Policy in the binding | The approval covers | | --------------------- | --------------------------- | --------------------------- | | `paytm:refund` | `customer-refund` 1.2.0 | the order, up to the amount | | `github:pr-merge` | `github-pr-approval` 1.1.0 | the pull request | | `github:pr-fetch` | `github-pr-read` 1.1.0 | the pull request | | `hubspot:deal-update` | `hubspot-deal-update` 1.1.0 | the deal | | `hubspot:deal-fetch` | `hubspot-deal-read` 1.0.0 | the deal | | `slack:post-message` | `slack-post-message` 1.1.0 | the channel | The reference policies with no connector need one too, for the Intent's `target`: `vendor-payment` 2.1.0, `access-control` 1.1.0, `agent-vendor-payment` 1.1.0, `api-key-issuance` 1.1.0, `connector-capability` 1.1.0, `database-change` 3.1.0, `expense-reimbursement` 1.1.0, `production-deployment` 1.1.0, `rag-document-access` 1.1.0 and `llm-tool-call` 1.1.0. The payment and expense policies also cover the amount. Older versions that approved without a person stay in the repository as history and are refused when they load. Production runs the version most recently approved through policy governance. Every version in the table and list above was approved in production on 2026-09-30 and is in effect. Ask the server which version applies to an action with `GET /policies/in-effect?capability=`, and send that version. ## Approve policies In production, a policy authorizes nothing until one person proposes it and a second person approves it with a signed step up authorization. The same person cannot do both. The whole process, with commands, is in [Policy lifecycle and approvals](/guides/policy-lifecycle-and-approvals). ## Approve a refund The `customer-refund` 1.2.0 policy authorizes a refund only with a signed approval from a manager: | Refund amount | What it needs | | ----------------- | -------------------------------- | | 0 or less | Refused | | Above 0 to 100000 | A signed approval from a manager | | Above 100000 | Refused, even with an approval | Why every refund: the agent declares `refundEligible` and `fraudCheckPassed` itself, and nothing checks them. In 1.1.0 a refund up to 10000 was authorized on those two claims alone, so an agent manipulated by prompt injection could approve its own refunds (G-75). In 1.2.0 they can only refuse a refund. `customer-refund` 1.2.0 has been in effect in production since 2026-09-28. A refund of any amount without a signed manager approval is refused with `403 POLICY_DENIED`. There is no amount below which a refund runs on its own. The flow for a refund of 75000 on order `ORD-1042`: The agent sends the refund with `managerApproved: false`. Parmana refuses it with `403 POLICY_DENIED`: the refund needs a signed manager approval. Nothing is executed, and a signed [Refusal Record](/concepts/refusal-records) is stored. The manager finds the refusal, for example with the query in [Review refused requests](#review-refused-requests). ```bash Script npx tsx scripts/sign-approval.ts \ --private-key-file ~/.parmana/manager-priya__manager-priya-key-1.private.pem \ --approver-id manager-priya --key-id manager-priya-key-1 \ --capability paytm:refund --resource-id ORD-1042 --max-amount 75000 \ --out approval.json ``` ```typescript TypeScript import { readFileSync, writeFileSync } from "node:fs"; import { signApproval } from "@parmana/sdk"; const approval = signApproval({ privateKeyPem: readFileSync("manager-priya__manager-priya-key-1.private.pem", "utf8"), approverId: "manager-priya", keyId: "manager-priya-key-1", capability: "paytm:refund", resourceId: "ORD-1042", maxAmount: 75000, }); writeFileSync("approval.json", JSON.stringify(approval)); ``` ```python Python import json from pathlib import Path from parmana.crypto import sign_approval approval = sign_approval( private_key_pem=Path("manager-priya__manager-priya-key-1.private.pem").read_text(), approver_id="manager-priya", key_id="manager-priya-key-1", capability="paytm:refund", resource_id="ORD-1042", max_amount=75000, ) Path("approval.json").write_text(json.dumps(approval)) ``` The approval is for this order only, for refunds up to 75000, valid for 15 minutes (`--ttl-seconds` or `ttlSeconds` changes it, up to a day), and can be used once. The SDKs need no clone of the repository; the private key never leaves the process. A new `businessTransactionId`, the same order and amount, `managerApproved: true`, and the contents of `approval.json` in `signals.approvalArtifact`: ```json "signals": { "refundEligible": true, "fraudCheckPassed": true, "refundAmount": 75000, "managerApproved": true, "approvalArtifact": { "payload": { "...": "..." }, "signature": { "...": "..." } } } ``` Before authorizing, Parmana checks that the approval is signed by a trusted approver, has not expired or been revoked, is for `paytm:refund`, for this order (the Intent's `parameters.orderId`), covers this amount (the Intent's `parameters.amount`), and has not been used before. The gateway runs the same checks again just before release. Then the refund runs. The policy says which order and amount to check, in its `approvalSignals` (see [Add an approval to any action](#add-an-approval-to-any-action)). `managerApproved: true` without a valid approval is refused, at any amount: ```text Execution rejected: Rejected: declared signal(s) do not match independently verified state (managerApproved=true != verified managerApproved=false). ``` The same happens for an approval for another order, a smaller amount, another action, a changed payload, an unknown or revoked approver, an expired approval, or one already used. ## Set up an approver An approver is a person with an Ed25519 key pair. The private key stays on their machine; the server trusts the public key. Add one through maker checker, with no deploy: ```bash npx tsx scripts/generate-approver-key.ts --approver-id manager-priya --key-id manager-priya-key-1 --out-dir ~/.parmana ``` They send the public key file, `manager-priya__manager-priya-key-1.public.pem`, to a proposer. `POST /approval-issuers/changes` with `action: "add"`, the ids, the public key and a reason. `POST /approval-issuers/changes/{id}/approve` with a step up authorization. The key is trusted from the next request on. The full walkthrough, with rotation, revocation and errors, is [Manage approvers](/guides/manage-approvers). To try the flow without a key pair, use the public sandbox: `POST /sandbox/approvals` signs a demo approval for `sandbox:receipt`, valid 300 seconds. See [Playground](/playground). It exists only in the sandbox. Approvers can also be listed in the server code, `TRUSTED_APPROVAL_ISSUERS` in `packages/api/src/bootstrap/codeApprovalIssuers.ts`. Those change only with a pull request and a deploy. The code list is checked first. The hosted deployment's code list holds one key, `manager-charak1987-key-1`, revoked on 2026-09-30; its current approver key was added through maker checker. Approver keys are always Ed25519, whatever signing algorithm the server itself uses. ## Add an approval to any action A policy declares which of its signals need a signed approval in `approvalSignals`, with a path to the resource in the Intent and, optionally, a path to a number the approval must cover. For a merge that needs a release manager: ```json "approvalSignals": { "releaseManagerApproved": { "resourceId": "target" } } ``` A rule then reads the signal like any other, for example `{ "fact": "releaseManagerApproved", "operator": "is_true" }`. The approver signs without `--max-amount`, since there is no amount; the approval then names exactly this pull request, `acme/api#42`: ```bash npx tsx scripts/sign-approval.ts --private-key-file ~/.parmana/lead-sam__lead-sam-key-1.private.pem --approver-id lead-sam --key-id lead-sam-key-1 --capability github:pr-merge --resource-id "acme/api#42" --out approval.json ``` The change goes live like any policy change: a new version, proposed and approved through policy governance. It takes effect when it is approved, with no code change and no deploy. One thing still needs a deploy: binding a built in connector's action to a different policy name. An action registered as an [external connector](/guides/connect-any-external-system) names its policy in its registration, through maker checker, with no deploy. Approvers are added through [Manage approvers](/guides/manage-approvers). Rules for a declaration, checked when the policy loads: - `resourceId` is `"target"` (the Intent's target, such as `acme/api#42` for a pull request) or a path into the Intent's `parameters`, such as `parameters.orderId`. A number is compared as its decimal string. `value` is a path into `parameters` to a number. - The approval's `scope.field` must be `value` when the declaration gives a `value`, and `resourceId` when it does not. The SDKs' `signApproval` and `scripts/sign-approval.ts` write these names. An approval scoped to any other field is refused, even when its bound would compare true. - The key must be read by a rule, and must not also be in `boundSignals`. - With more than one approval signal, give each its own `artifact`, the signal that carries its approval. The default is `approvalArtifact`. ## Approve a HubSpot update or read `hubspot-deal-update` 1.1.0 authorizes every deal update only with `dealUpdateApproved: true` and a signed approval for that deal in `signals.approvalArtifact`, whatever the amount. Sign it with `--capability hubspot:deal-update` and the deal id as `--resource-id`, without `--max-amount`. The dealstage transition is still checked against the real deal, and a transition that is not allowed is refused even with an approval. Reading a deal (`hubspot:deal-fetch`) has its own policy, `hubspot-deal-read` 1.0.0. It needs `readApproved: true` with an approval for that deal, signed with `--capability hubspot:deal-fetch`. Under 1.0.0 an update within the amount threshold needed no person, and only a larger amount change needed `preAuthorizedForAmountChange` with an approval. 1.0.0 now fails to load. ## Review refused requests Every request that a policy refuses is stored with a signed [Refusal Record](/concepts/refusal-records). To list recent refunds that were refused: ```sql select refusal_record_id, business_transaction_id, submitted_by, created_at, decision_json ->> 'reason' as reason, evaluated_intent_json -> 'parameters' as parameters from refusal_records where decision_json -> 'policy' ->> 'name' = 'customer-refund' order by created_at desc limit 50; ``` To list only the refunds waiting for a manager, add `and decision_json ->> 'matchedRuleId' = 'reject-manager-approval-required'`. Requests refused before policy runs, such as a failed authentication or a capability the caller is not allowed to use, are in `caller_audit_events`: ```sql select occurred_at, caller_id, capability, type, reason from caller_audit_events where type in ( 'caller.rejected', 'caller.capability_denied', 'caller.principal_denied', 'caller.non_human_denied', 'caller.structural_rejected' ) order by occurred_at desc limit 50; ``` A Refusal Record is written after the request is refused, and a failed write does not change the refusal. A query can therefore miss a refused request. Each miss is logged as `refusal_record_write_failed`. ## Get notified The full guide, with receiver code in Node.js and Python, is [Approval notifications](/guides/approval-notifications). Set `APPROVAL_WEBHOOK_URL` and `APPROVAL_WEBHOOK_SECRET`, and Parmana POSTs an event to that URL each time a request is refused **only** for want of an approval: the policy declares `approvalSignals`, and the same request with those signals true would have been authorized. A refund refused for a failed fraud check, or above the maximum, sends nothing. ```json { "type": "approval.needed", "occurredAt": "2026-09-29T10:15:02.114Z", "businessTransactionId": "5f0c...", "decisionId": "a91e...", "action": "paytm:refund", "target": "paytm://orders/ORD-1042", "policyId": "customer-refund", "policyVersion": "1.2.0", "reason": "...", "approvals": [ { "signal": "managerApproved", "resourceId": "ORD-1042", "value": 75000 } ] } ``` `approvals` is what to sign: `--capability` is `action`, `--resource-id` is `resourceId`, and `--max-amount` is `value` when there is one. The event carries nothing else from the request. Each request has two headers. `parmana-webhook-timestamp` is Unix seconds. `parmana-webhook-signature` is `v1=` and the lowercase hex HMAC SHA256, keyed with the secret, of the timestamp, a dot, and the exact body. Check it before acting, and refuse an old timestamp: ```typescript import { createHmac, timingSafeEqual } from "node:crypto"; function isFromParmana( secret: string, timestamp: string, body: string, signature: string, ) { const expected = "v1=" + createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex"); const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300; return ( fresh && expected.length === signature.length && timingSafeEqual(Buffer.from(expected), Buffer.from(signature)) ); } ``` Delivery is best effort: one attempt, at most 3 seconds, no retry. A failed delivery is logged as `approval_needed_notification_failed` and changes nothing about the refusal. The refused requests query above stays the complete list. ## Not available **A refused request cannot be approved afterwards.** A policy decision is approve or refuse. Nothing waits for a person. After a manager signs an approval, the agent sends a new request. **Eligibility and fraud signals are still the caller's word.** `refundEligible` and `fraudCheckPassed` are not checked against another system (G-51 in `docs/VERIFICATION-GAPS.md`). They can only refuse; the signed approval authorizes. **An approval covers the action and the resource, not every parameter.** An approval for a Slack channel does not fix the message text, and an approval for a HubSpot deal does not fix the new amount or stage. The approver trusts the agent with those details for that one use. An approval for a payment, an expense or a refund does cover the amount. **There is no automatic path.** No action is authorized without a person, by design. Adding one would need a server side check that reads the facts itself, and a change to this rule (G-80 in `docs/VERIFICATION-GAPS.md`). ## Why each action is bound to one policy Each request names the policy that should judge it. If Parmana accepted any approved policy, a caller could send a refund and name a policy written for Slack messages, which has no refund limit. So each live action is bound to one policy name: ```text paytm:refund -> customer-refund github:pr-merge -> github-pr-approval github:pr-fetch -> github-pr-read hubspot:deal-update -> hubspot-deal-update hubspot:deal-fetch -> hubspot-deal-read slack:post-message -> slack-post-message ``` For a built in connector the name is fixed in code; for an external connector it is the policy its active registration names (in the sandbox, `sandbox:receipt -> sandbox-receipt`). The version is not fixed: in production it is the version most recently approved through policy governance. Approving `customer-refund` 1.2.0 makes 1.2.0 the version refunds must use, with no deploy, and approving 1.1.0 again rolls back. A request that names a different policy, or any other version, is refused before any policy runs, with a message that names the version in effect. Agents must then send that version. --- ## Connect an External Agent Source: https://docs.parmanasystems.com/guides/connect-an-agent Covers the caller (agent) side. The repository's `docs/connectors/CONNECTING_AN_AGENT.md` points here. For what happens after approval (gateway, connector, the real business system), see [Connect any external system](/guides/connect-any-external-system), the [Connector Development Guide](/integrations/connector-development-guide) and `docs/connectors/PAYTM_CONNECTOR.md`. ## The mental model ```text AI Agent ---------------------------------------------------> Parmana | understands the request, extracts an intent | | never decides whether the action is authorized | v v "I want to refund order X for ₹500" caller auth -> capability check -> CapabilityPolicyBinder -> SignalIntentBinder -> PolicyEngine.evaluate -> APPROVE or REJECT (binary, signed) ``` The agent proposes an intent. Parmana decides whether it's authorized. Only Parmana's decision can unlock execution. If your integration ever lets the agent set a value that determines the outcome , an approval flag, a fraud-check result, a policy name, the model is broken, even if it "works." ## What you need, and why | # | You need | Why | | --- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | A reachable Parmana deployment | Nothing to connect to without one. | | 2 | A capability name (e.g. `paytm:refund`) | Parmana authorizes capabilities, not free-form actions. Exact string matching, `refund` ≠ `paytm:refund`. | | 3 | A deployed policy bound to that capability | Decides APPROVE/REJECT. Missing → `404`; a version other than the one in effect → `403 POLICY_DENIED`. | | 4 | The policy's exact `signalsSchema`/`boundSignals` | You must send every referenced signal; any `boundSignals` entry must equal the real `intent.parameters` value or the request is rejected before evaluation. | | 5 | An API key scoped to exactly that capability | Your agent's identity, see below. | | 6 | A trusted-signal source that is not the conversation | Signals must come from an independent business system, never inferred from what the customer said. | | 7 | Real UUID generation | `businessTransactionId` is checked against a UUID shaped regex (versions 1 to 5); anything else is a `400` before any other logic runs. | | 8 | A registered connector for your capability on this deployment | Without one an approved request is refused with `503 CONNECTOR_NOT_REGISTERED` and nothing runs. See step 8 below. | ## Step by step **1. Read the policy file directly.** `cat policies/customer-refund/1.2.0/policy.json`; don't guess field names from a description. Ask the server which version is in effect: `GET /policies/in-effect?capability=paytm:refund`. **2. Pick the exact capability string.** Case- and character-exact. This single detail is the most common integration failure in practice. **3. Mint a scoped API key:** ```bash npx tsx scripts/generate-api-key.ts \ --caller-id my-refund-agent \ --allowed-capabilities "paytm:refund" \ --credential-holder-type SERVICE ``` Use `SERVICE`, never `"AGENT"`, that value doesn't exist in `AuthorityType`. Never grant `"*"` to a single-purpose agent; `/callers/me`'s `unrestrictedCapabilities` is literally `allowedCapabilities.includes("*")`. **If you're the deployment operator adding this key** (not just the agent developer): `PARMANA_API_KEYS` is one environment variable holding a JSON **array**. Two real mistakes were made hand-editing a live deployment's value in a hosting dashboard: pasting the new entry as the *entire* value instead of appending it to the array (every caller gets a 500 until fixed, the server fails closed on malformed JSON), and losing existing entries by replacing rather than appending. Always paste the complete array, every existing entry plus the new one. Full account: the 2026-09-15 KMS migration troubleshooting guide (in git history). **4. Verify the key before writing agent code:** ```bash curl -i "https:///callers/me" -H "Authorization: Bearer " ``` If this fails, fix authentication first, nothing about your agent's request shape is relevant yet. **5. Wire up real trusted-signal sources** for every field in the policy's `signalsSchema`. Not a placeholder to fix later, from day one. **6. Build the request:** **This example was missing `metadata` until 2026-09-14**, found while running [End-to-end: agent → Parmana → Paytm](/guides/end-to-end-paytm-flow) against a real deployment. Omitting it fails with `{"error":"metadata.businessTransactionId must match businessTransactionId."}` (400), before policy is ever evaluated. It's now included below. ```json { "businessTransactionId": "", "metadata": { "businessTransactionId": "" }, "authority": { "authorityId": "", "authorityType": "SERVICE", "principalId": "my-refund-agent", "issuedAt": "" }, "authorization": { "authorizationId": "", "authorityId": "", "purpose": "Authorize Paytm customer refund", "issuedAt": "" }, "intent": { "intentId": "", "authorizationId": "", "action": "paytm:refund", "target": "", "parameters": { "orderId": "", "transactionId": "", "amount": 500 }, "createdAt": "" }, "policy": { "name": "customer-refund", "version": "1.2.0", "schemaVersion": "1.0.0" }, "signals": { "refundEligible": true, "managerApproved": true, "fraudCheckPassed": true, "refundAmount": 500, "approvalArtifact": { "...": "the signed manager approval for this order and amount" } }, "status": "RECEIVED" } ``` `policy.name`/`policy.version` is never inferred from `intent.action`: name it explicitly, or a caller could pair a real capability with an unrelated policy. Under `customer-refund` 1.2.0 every refund needs a signed manager approval (G-75); without one the request is refused with `403 POLICY_DENIED`, whatever your agent declares about eligibility and fraud. **7. Send it, handle the response**, see the complete reference table below. **8. Confirm a connector is registered** for your capability on this deployment. A `200` means the action was released and executed and its Trust Record signed; with no connector the answer is `503 CONNECTOR_NOT_REGISTERED` and nothing ran. To practise end to end with nothing installed, use the public sandbox's `sandbox:receipt` ([Playground](/playground)). To connect your own system without code, see [Connect any external system](/guides/connect-any-external-system). **9. Verify independently**: `GET /refusal/:id` + `POST /refusal/verify` for a rejection, `GET /trust-records/:id` (only once a connector is wired) for an execution, or fully offline with `verifyExecutionTrustRecordOffline`. ## Complete response reference Every row is what the server returns today (2026-10-02); the [Error catalog](/api-reference/error-catalog) has every code. | Status | `code` | Meaning | Do this | | ------ | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `200` | none | Approved, released to the connector, executed, and the signed Execution Trust Record is the response. | Record the `trustRecordId`. This is the only answer that means the action ran. | | `400` | none | Invalid `businessTransactionId`, malformed JSON, or a field that breaks the transaction's own rules. | Fix the request: the message names the field. | | `401` | none | No key, or a key the server does not know. | Check the key with `GET /callers/me`. | | `403` | none | Your key may not assert this `authority.principalId`. | Use your own `callerId`, or ask the operator for `allowedPrincipalIds`. | | `403` | `CAPABILITY_NOT_ALLOWED` | `intent.action` is not in your key's `allowedCapabilities`. **The most common real bug.** | Compare `/callers/me`'s `allowedCapabilities` with your `intent.action`, character for character. | | `403` | `POLICY_DENIED` | The policy refused: a rule matched, the approval is missing, expired, for something else or already used, or the policy version is not the one in effect. The message names the reason. | Respect it. For a missing approval, get a signed approval and send a **new** request. Never retry with altered parameters. | | `404` | none | `policy.name` or `version` does not exist on this deployment. | Ask `GET /policies/in-effect?capability=...`. | | `409` | none | `businessTransactionId` already used. | Read what happened to it, see [Idempotency](/api-reference/idempotency-and-nonces). | | `429` | `RATE_LIMITED` | Too many requests for this key. | Wait for `Retry-After`, then retry the same request. | | `502` | `EXECUTION_OUTCOME_UNKNOWN` | Released, but the call to the connector failed. It may have run. | **Do not send it again as a new transaction.** The operator checks the target system and resolves the intent. | | `503` | `CONNECTOR_NOT_REGISTERED` | Approved, but no connector serves this capability here. Nothing ran. | The operator configures the connector; then send a new request. | | `503` | `SIGNING_UNAVAILABLE`, `EXECUTION_INTENT_UNAVAILABLE` | The server could not sign or store evidence before release. Nothing ran. | Retry later with a new `businessTransactionId`. | | `500` | `EXECUTION_RECORD_INCOMPLETE` | Released, then the Trust Record could not be stored. The action ran. | **Do not send it again.** The operator finalizes the intent. | | `500` | none | Anything unexpected, including a gateway rejection of a tampered envelope. | Ask the operator to read the server log before assuming your request was wrong. | | (none) | none | Timeout or network error. Unknown whether Parmana received it; an approved request can take tens of seconds (G-84). | Set the timeout to 120 seconds. After a timeout, read `GET /trust-records/{id}` and `GET /refusal/{id}` with the same id before anything else. | ## Common mistakes - Capability string mismatch between `intent.action` and your key's `allowedCapabilities`. - `authorityType: "AGENT"`, invalid; use `"SERVICE"`. - A signal inferred from the customer's words instead of an independent business system. - Treating `POLICY_DENIED` as a bug instead of a correct decision. - Treating a timeout as a failure and sending the action again under a new id. - Granting `"*"` "to get it working" and never narrowing it. - Hand-editing `PARMANA_API_KEYS` by pasting a single entry instead of the complete array. - Assuming a bare `500` means your request was wrong: ask for the server log first. ## Reference implementation `pavancharak/parmana-phinite-agent` is a real, working implementation of every step above. Two bugs were found and fixed in it: the exact `CAPABILITY_NOT_ALLOWED` mistake from the table above (`"refund"` instead of `"paytm:refund"`), and error handling that crashed instead of resolving unrecognized failures to an unresolved client-side state. See that repository's README for the full writeup. **Second round, entirely on Parmana's own side (2026-09-15/16):** after those agent-side fixes, this same integration hit a sequence of purely Parmana-operator issues onboarding as a new caller on a freshly KMS-migrated deployment: no `PARMANA_API_KEYS` entry at all (401) → a malformed hand-edited entry (500 for every caller) → no `allowedPrincipalIds` grant for the asserted principal (403) → a genuine Parmana-side bug where the gateway's signing and verification paths silently used different keys after a KMS migration (500, unrelated to the request itself , `examples/tutorials/114-signing-verification-key-agreement/` reproduces it). None of these were agent bugs. Full account: the 2026-09-15 KMS migration troubleshooting guide (in git history). ## Next What happens after APPROVED: Execution Gateway, connector dispatch, execution evidence. Call the real, deployed Parmana API directly and see what's and isn't wired on it today. --- ## Connect any external system Source: https://docs.parmanasystems.com/guides/connect-any-external-system This page connects a system Parmana has no built in connector for, such as your ERP, CRM or an internal service, with no change to Parmana's code (ADR-0013). You run a small HTTPS endpoint in front of the system. Parmana releases every approved request for the action to that endpoint as a **signed release**; the endpoint checks it with an SDK helper, acts with its own credentials, and answers. Parmana holds none of the system's credentials. **Status, 2026-10-01.** Registering a connector through maker checker, and releasing approved requests to it, are live in production and were checked end to end against a deployed endpoint (`docs/CLAIMS.md` 2.49 and 2.50). The SDK helpers `verifyParmanaRelease` and `verify_parmana_release` are published in SDK 1.5.0 (2026-10-04) on npm and PyPI; with an older SDK, build the endpoint from the repository's SDK packages, as `examples/live-checks/external-connector/build-endpoint.ts` does. An approved request took 24 to 32 seconds in production (G-84): give the agent a timeout of at least 120 seconds. Follow it literally, as `agents/integrate` is followed. If your situation is not covered, **stop and ask the operator**. ## Rules that apply to every step 1. Do the steps in order. Do not skip one. 2. At each step, compare what you received with **Expect**. If it differs, follow **If not** and stop. 3. Never invent a value. Every input comes from the table below or from a response. 4. The endpoint acts only on what a verified release names. It never acts on a request that did not come from Parmana. 5. Two different people propose and approve every change. One person holding both keys defeats the control. ## Inputs you need before you start | Input | What it is | How to check it | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `PARMANA_URL` | The Parmana server, without a trailing slash. | Step 1. | | `PROPOSER_KEY` | The maker's API key, added as a human (`credentialHolderType USER`). | `GET /callers/me` answers `200`. | | `APPROVER_KEY` | A second person's API key, added as a human, with a step up public key registered on it, and that person's step up private key on their own machine. | See [Policy lifecycle and approvals](/guides/policy-lifecycle-and-approvals), "Set up the people, once". | | `CAPABILITY` | The action, `namespace:verb`, for example `erp:create-invoice`. Lowercase letters and digits, words joined by `-` or `_`, at most 128 characters. | Not in a built in namespace: `paytm`, `hubspot`, `github`, `slack`, `test`. | | `POLICY_NAME` | The policy that governs `CAPABILITY`, for example `erp-invoice`. | Lowercase letters, digits and `-`. | | `POLICY_VERSION` | Its version, for example `1.0.0`. | | | `ENDPOINT_URL` | Where the endpoint will be reached, for example `https://erp.example.com/parmana/release`. | `https`, a host name with a domain, no IP address, no `localhost`, resolving only to public addresses. | | `PARAMETERS` | The only parameter names the endpoint needs, for example `amount`, `currency`. Parmana refuses to forward any other. | At most 64 names, each `^[A-Za-z_][A-Za-z0-9_]{0,63}$`. | | `AGENT_CALLER_ID` | The caller id of the agent's key that will send requests. | Step 9. | | An action approver | The person who signs approvals for `CAPABILITY`, trusted by the server. | Step 10. | ## Step 1: check the server **Do:** `GET {PARMANA_URL}/health` and `GET {PARMANA_URL}/ready`. **Expect:** `200 {"status":"UP"}`, and `200` with `"status":"READY"` and `"authDisabled":false`. **If not:** stop. See [Integrate Parmana](/agents/integrate), step 1. ## Step 2: write the policy **Do:** write `POLICY_NAME` at `POLICY_VERSION`. Every policy must require a signed approval: declare one approval signal in `approvalSignals`, name the resource it approves (`"target"` or a path into `parameters`), and make every approve rule require that signal with `is_true`. A fact the caller declares can refuse, never authorize: give each one a reason in `unboundSignalReasons`. ```json { "policyId": "erp-invoice", "policyVersion": "1.0.0", "schemaVersion": "1.0.0", "description": "Creates an invoice in the ERP only with a signed approval from a trusted person for that customer.", "signalsSchema": { "invoiceApproved": "boolean", "customerActive": "boolean" }, "approvalSignals": { "invoiceApproved": { "resourceId": "target", "value": "parameters.amount" } }, "unboundSignalReasons": { "customerActive": "Declared by the caller. It can refuse an invoice, never authorize one on its own." }, "rules": [ { "id": "reject-inactive-customer", "condition": { "fact": "customerActive", "operator": "is_false" }, "outcome": { "action": "reject", "reason": "The customer is not active." } }, { "id": "approve-with-approval", "condition": { "all": [ { "fact": "invoiceApproved", "operator": "is_true" }, { "fact": "customerActive", "operator": "is_true" } ] }, "outcome": { "action": "approve", "reason": "Invoice approved by a trusted person for this customer." } }, { "id": "reject-approval-required", "condition": { "always": true }, "outcome": { "action": "reject", "reason": "An invoice needs a signed approval for this customer in signals.approvalArtifact, with invoiceApproved true." } } ] } ``` With `"value": "parameters.amount"`, an approval also caps the amount. Leave `value` out if the action has no amount. **Expect:** a JSON file. Step 3 validates it on the server. ## Step 3: propose the policy (maker) **Do:** with `PROPOSER_KEY`, `POST {PARMANA_URL}/policies/{POLICY_NAME}/{POLICY_VERSION}/pending-changes` with `{"reason": "...", "proposedContent": }`. The exact commands, in Bash and PowerShell, are in [Policy lifecycle and approvals](/guides/policy-lifecycle-and-approvals), step 3. **Expect:** `201` with a `pendingPolicyChangeId` and `"status":"PENDING_APPROVAL"`. **If not:** 1. `400`: the policy is refused, and the message says why (for example, an approve rule that does not require the approval signal). Fix the file, then repeat this step. 2. `403 NON_HUMAN_CALLER_DENIED`: `PROPOSER_KEY` is not a human key. Stop. Ask the operator. 3. `409 CONFLICT`: a proposal for this version is already open. Approve or reject it first. ## Step 4: approve the policy (checker) **Do:** the second person reads the proposed content (`GET /policies/pending-changes?status=PENDING_APPROVAL`), signs a step up authorization for that change id and action `approve` on their own machine, and posts it to `/policies/pending-changes/{pendingPolicyChangeId}/approve` with `APPROVER_KEY`. Commands: [Policy lifecycle and approvals](/guides/policy-lifecycle-and-approvals), step 5. **Expect:** `200` and `"status":"APPROVED"`. This version is now the version in effect for `POLICY_NAME`. **If not:** `403 SAME_ACTOR_CANNOT_APPROVE_OWN_CHANGE` (the proposer's key was used: use the second person's) or `403 STEP_UP_AUTHORIZATION_INVALID` (signed for another change or action, expired after 120 seconds, or used before: sign again). Then repeat this step. ## Step 5: run the endpoint **Do:** deploy the example endpoint for your language, with `act` replaced by the call into your system: - TypeScript: `typescript/examples/07-external-connector-endpoint.ts`, `createReleaseHandler({ publicKeys, audience, act })`. - Python: `python/examples/13_external_connector_endpoint.py`, `create_release_handler(public_keys=..., audience=..., act=...)`. Give it Parmana's public key (`GET {PARMANA_URL}/keys/default`, or `client.publicKey("default")`) and `ENDPOINT_URL` exactly as you will register it. Serve it over HTTPS at `ENDPOINT_URL`. Keep its record of answers by `businessTransactionId` in durable storage, not in memory: Parmana may send the same release again after a timeout, and the endpoint must answer with its first result, not act twice. Before it acts, the helper checks, in order: the body's shape, the signature over the canonical release with the key named in `signature.keyId`, that `release.audience` is this endpoint, and that the release has not expired (60 seconds after it was issued, with 30 seconds of clock skew). It acts only on `capability`, `target` and `parameters` from the verified release, and answers `200` with: ```json { "businessTransactionId": "", "capability": "", "success": true, "result": { "invoiceId": "INV-991" }, "executedAt": "2026-10-01T10:00:02.000Z" } ``` **Expect:** `POST {ENDPOINT_URL}` with body `{}` answers `401` with `{"errors":["the body is not { release, signature }"]}`. An endpoint that acts on an unsigned body is wrong. **If not:** do not register it. Fix the endpoint first. ## Step 6: propose the registration (maker) **Do:** with `PROPOSER_KEY`: ```bash curl -s -X POST $PARMANA_URL/external-connectors/changes \ -H "Authorization: Bearer $PROPOSER_KEY" -H "Content-Type: application/json" \ -d '{ "action": "register", "capability": "erp:create-invoice", "endpointUrl": "https://erp.example.com/parmana/release", "policy": "erp-invoice", "allowedParameters": ["amount", "currency"], "timeoutMs": 10000, "reason": "Finance creates invoices in the ERP through Parmana." }' ``` `timeoutMs` is optional: 1000 to 30000, default 10000. From SDK 1.5.0, steps 6 and 7 are also one call each: `proposeExternalConnectorChange()` and `approveExternalConnectorChange()` in TypeScript, `external_connectors.propose_register()` and `approve_external_connector_change()` in Python. See [SDK coverage of the API](/sdks/api-coverage#external-connectors). **Expect:** `201` with a `changeId`, `"status":"PENDING_APPROVAL"`, and `endpointUrl` as stored (the host in lowercase). Use that stored `endpointUrl` as the endpoint's `audience` in step 5. **If not:** 1. `400 INVALID_EXTERNAL_CONNECTOR`: a field is malformed, or the namespace belongs to a built in connector. The message names the field. 2. `400 EXTERNAL_ENDPOINT_ADDRESS_REFUSED`: the endpoint is not `https`, names an IP address or `localhost`, does not resolve, or resolves to an address that is not public. Fix the host. 3. `409 CONFLICT`: the capability already has an active registration (revoke it first, see below), or a change for it is already pending. ## Step 7: approve the registration (checker) **Do:** the second person reviews it (`GET /external-connectors/changes?status=PENDING_APPROVAL`), then signs a step up authorization for the `changeId` and action `approve`. It is the same script as for a policy change: the change id goes in `--pending-policy-change-id`. ```bash npx tsx scripts/sign-policy-change-step-up.ts \ --private-key-file step-up.private.pem --key-id \ --pending-policy-change-id "$CHANGE_ID" --action approve > signed.txt curl -s -X POST $PARMANA_URL/external-connectors/changes/$CHANGE_ID/approve \ -H "Authorization: Bearer $APPROVER_KEY" -H "Content-Type: application/json" \ -d "{\"stepUpAuthorization\":$(grep '^{' signed.txt)}" ``` **Expect:** `200` and `"status":"APPROVED"`. `GET /external-connectors` lists the capability with `"status":"active"`. **If not:** 1. `400 EXTERNAL_ENDPOINT_ADDRESS_REFUSED`: the host now resolves to an address that is not public. The address is checked again at approval. Fix the DNS or reject the change. 2. `403` (`SAME_ACTOR_CANNOT_APPROVE_OWN_CHANGE` or `STEP_UP_AUTHORIZATION_INVALID`): as in step 4. 3. `409 CONFLICT`: the change was already resolved, or another registration became active. ## Step 8: check the policy in effect for the capability **Do:** `GET {PARMANA_URL}/policies/in-effect?capability={CAPABILITY}` with any human key or the agent's key. **Expect:** `200` with `"policy": {"name": POLICY_NAME, "version": POLICY_VERSION, "schemaVersion": "1.0.0"}` and `signals.approval` naming your approval signal. **If not:** 1. `409 NO_APPROVED_POLICY_VERSION`: no version of `POLICY_NAME` is approved. Do steps 3 and 4. Every request is refused until then. 2. `404 CAPABILITY_NOT_BOUND`: the registration is not active. Check step 7. 3. `503 POLICY_VERSION_UNAVAILABLE`: the server could not read it. Try again; if it persists, tell the operator. ## Step 9: grant the capability to the agent's key (operator) **Do:** add `CAPABILITY` to `allowedCapabilities` of the agent's entry in the server's API keys, keeping every other entry, and deploy. See the warning in [Connect an agent](/guides/connect-an-agent), step 3. **Expect:** `GET {PARMANA_URL}/callers/me` with the agent's key lists `CAPABILITY` in `allowedCapabilities`. **If not:** the key was not updated or the deploy has not finished. Do not grant `"*"` instead. ## Step 10: make sure an approver is trusted **Do:** `GET {PARMANA_URL}/approval-issuers` with a human key. **Expect:** the action approver's `approverId` and `keyId`, with `"revoked": false`. **If not:** add the approver's key through maker checker: [Manage approvers](/guides/manage-approvers). ## Step 11: send requests The agent now follows [Integrate Parmana: specification for AI agents](/agents/integrate) from step 3, with `ACTION = CAPABILITY`. Nothing in that procedure differs for an external connector. The first request for a resource is refused until the approver signs an approval for it: ```bash npx tsx scripts/sign-approval.ts \ --private-key-file --approver-id --key-id \ --capability erp:create-invoice --resource-id customer-42 --max-amount 1200 --out approval.json ``` The agent sends a new request with the approval signal `true` and `approval.json` in `signals.approvalArtifact`. **Expect:** `200` with an Execution Trust Record. The endpoint received one release naming the capability, the target, the parameters, the policy at its version, the approver, the authorization id and the endpoint as audience, and acted once. The endpoint's answer is recorded in the record as its claim. **If not:** 1. `503 CONNECTOR_NOT_REGISTERED`: there is no active registration for the capability. Nothing was sent. Check step 7. 2. `502 EXECUTION_OUTCOME_UNKNOWN`: the release failed. The endpoint may have acted: it answered something other than `200`, timed out, answered without echoing `businessTransactionId` and `capability`, or its `result` was not an object of at most 16 KB. A request naming a parameter outside `PARAMETERS`, or an endpoint whose host now resolves to a non public address, is also reported this way, although nothing was sent. **Do not resend as a new transaction.** Check the endpoint's own record, then close the Execution Intent with `POST /execution-intents/{businessTransactionId}/resolve`. 3. Any refusal before release (`403`, `400`): as in [Integrate Parmana](/agents/integrate), step 6. ## Execute only if still valid When the policy sources facts from a business system ([Business validation](/concepts/business-validation)), the release carries them as `conditions`, signed with the rest: ```json "conditions": [ { "signalKey": "invoicePaid", "source": "erp", "claim": "invoice.paid", "subject": "INV-1001", "value": true, "validUntil": "2026-10-11T12:01:00.000Z" } ] ``` The release expires no later than the earliest `validUntil`. Check each condition in your own records in the same transaction as the action, and act only if every one still holds. If one does not, do not act, and answer `200` with `"success": false`, `"conditionsNotMet": true` and a `result`; Parmana records that nothing was done. The example endpoints take this check as `conditionsHold` (TypeScript) and `conditions_hold` (Python). An endpoint that ignores `conditions` still works, without the guarantee. ## Change or remove a connector - **Move to a new endpoint:** propose `{"action":"revoke","capability":"...","reason":"..."}`, approve it (step 7), then register the new endpoint (steps 6 and 7). From the approved revoke until the new registration is approved, requests are refused with `503 CONNECTOR_NOT_REGISTERED`. - **Change the policy:** propose and approve a new version of `POLICY_NAME` (steps 3 and 4). It takes effect at once; the registration does not change. - **Stop it:** revoke it. Nothing is deleted: `GET /external-connectors` keeps listing it as `revoked`, so every release ever made to it stays explainable. ## Rules that are never broken - Parmana releases only to an `https` host that resolves only to public addresses, checked when the registration is proposed, when it is approved, and at every release. It connects to the address it checked and follows no redirect. - A release names one capability, target and parameter set a person approved, is addressed to one endpoint, and expires 60 seconds after it is issued. No shared secret is involved: the signature is the authentication. - What the endpoint answers is its claim, not proof that it acted (`docs/VERIFICATION-GAPS.md` G-82). ## See it run - Tutorial 123: `npx tsx examples/tutorials/123-external-connector/run.ts` registers an ERP endpoint through maker checker, releases to the TypeScript example endpoint, answers a retry once, refuses a release for another endpoint, and stops at a revoke. - The `/external-connectors` operations in the [API reference](/api-reference/endpoints/propose-external-connector-change), with real responses for every refusal. - The design and its security reasoning: `docs/adr/ADR-0013-Generic-External-Connector.md`. --- ## Connector Development Guide Source: https://docs.parmanasystems.com/integrations/connector-development-guide **[AVAILABLE] as a library**, `packages/connector-sdk`, **[AVAILABLE] wired into the default server** for HubSpot, GitHub, Paytm, and Slack, each registering only when its own credentials are configured. Adding another connector in code is a code change, see the warning below. To connect a system you run an HTTPS endpoint for, with no code change, register an external connector instead: [Connect any external system](/guides/connect-any-external-system). Examples below are TypeScript. Building a connector from Python instead? The identical contract is published as [`parmana-connector-sdk` on PyPI](https://pypi.org/project/parmana-connector-sdk/) — see [Connector SDK (Python)](/reference/connector-sdk-python). ## Connector responsibilities, and what a Connector must never do A Connector validates a request, executes using an already-resolved credential, and returns a response with a fixed, predictable shape. A Connector never evaluates policy, authorizes execution, interprets AI output, makes a business decision, or resolves a credential itself, credential resolution happens exclusively inside the Execution Gateway, before a Connector is ever called. ```typescript import { connectorCapabilities, type Connector, type ConnectorExecutionContext, type ConnectorRequest, type ConnectorResponse, } from "@parmana/connector-sdk"; class ExampleConnector implements Connector { readonly connectorId = "example"; readonly capabilities = connectorCapabilities(["crm:read"]); async execute( request: ConnectorRequest, context: ConnectorExecutionContext, ): Promise { if (!this.capabilities.includes(request.capability)) { throw new Error(`does not declare capability "${request.capability}"`); } // context.credential is an opaque CredentialHandle, read it, never log it. return { success: true, metadata: { recordId: "example-1" } }; } } ``` ## Capabilities are namespaced verbs `ConnectorCapability` is a plain string, validated eagerly by `connectorCapabilities()` against `namespace:verb` (e.g. `http:get`, `crm:read`, `payments:refund`), a malformed capability throws at connector construction, not at execution time. By convention, `ExecutableContent.action` _is_ the capability string; this is what `execution-control`'s `DefaultConnectorPolicy` already checks unchanged. ## Registering a connector **This step is internal, not something `npm install @parmana/connector-sdk` alone gives you.** `@parmana/connector-sdk` lets you build and test a `Connector` in isolation. Wiring a built connector into a live Parmana deployment happens through `GatewayConnectorRegistry` (`@parmana/execution-gateway`) — a private, unpublished package internal to this repository, the same way `packages/api/src/bootstrap/*` registers HubSpot, GitHub, Paytm, and Slack today. If you're building a connector against the published `@parmana/connector-sdk` for your own deployment, this section shows the shape of that internal step, not an API surface you can call from outside this repository. ```typescript import { GatewayConnectorRegistry } from "@parmana/execution-gateway"; import { StaticCredentialProvider, healthyNow } from "@parmana/connector-sdk"; const registry = new GatewayConnectorRegistry(); registry.register({ connector: new ExampleConnector(), metadata: { connectorId: "example", displayName: "Example CRM", version: { major: 1, minor: 0, patch: 0 }, health: healthyNow(), }, connectorIdentity: { connectorId: "example", publicIdentity: "spiffe://parmana/connectors/example", authenticationMetadata: {}, }, credentialProvider: new StaticCredentialProvider({ example: { token: "..." }, }), policy, // a ConnectorPolicy, e.g. CapabilityConnectorPolicy(new DefaultConnectorPolicy(...)) gatewayAuthentication, crypto, // @parmana/crypto CryptoProvider, reused for evidence hashing, not reimplemented }); ``` `registry` implements `execution-control`'s `ConnectorRegistry` interface (`get()`), so it plugs directly into `ExecutionControlService` exactly like `InMemoryConnectorRegistry`. ## Testing your connector hermetically Use `MockConnector` to test anything upstream of your connector (policy wiring, routing) without a real target system: ```typescript import { MockConnector, connectorCapabilities } from "@parmana/connector-sdk"; const mock = new MockConnector({ connectorId: "example", capabilities: connectorCapabilities(["crm:read"]), script: { respond: () => ({ success: true, metadata: { recordId: "example-1" } }), }, }); const result = await mock.execute( { capability: "crm:read", businessTransactionId: "t1", action: "crm:read", target: "crm://record/1", parameters: {}, }, { credential: undefined as never }, ); ``` Real output, checked directly against this exact code: ``` mock result: { success: true, metadata: { recordId: 'example-1' } } mock invocations: 1 ``` To test failure handling, use `script: { failWith: new Error("upstream unavailable") }`. `mock.invocations` records every `ConnectorRequest` the connector received, so tests can assert exactly what was executed. ## Version and health checks fail closed `SdkConnectorExecutor` rejects execution, before invoking your connector, if: - an `expectedVersion` was configured at registration and the connector's own `metadata.version` doesn't match (guards a rolling deployment that swapped connector builds underneath a pinned expectation), or - `metadata.health.status === "unavailable"`. ## What every connector's evidence looks like Every execution produces a `ConnectorEvidence` object, connector ID, version, capability, a sanitized endpoint (credentials and query parameters stripped), the credential provider's ID, redacted request/response summaries, timestamps, and a hash computed by the existing `TrustRecordHasher`, attached at `ExecutionResult.metadata.connector`. See [Execution Trust Record](/concepts/execution-trust-records) for how this reaches the Trust Record without any change to its schema or hashing pipeline. ## What reaching the default server actually requires This is the part that's easy to assume is configuration and isn't. A capability is only reachable on the default server when its connector is registered in `createConnectorRegistry.ts`, gated behind that connector's own credentials being configured. There is no unconditional or environment-only registration path: a capability with a signed `APPROVED` decision but no registered connector fails closed at dispatch, it never silently succeeds. The real, current registration pattern, copied verbatim from source: ```typescript // packages/api/src/bootstrap/createConnectorRegistry.ts (abridged, real source) const hubspotCredentialProvider = createHubSpotCredentialProvider(); if (hubspotCredentialProvider === undefined) { console.warn({ event: "hubspot_connector_unavailable", reason: "HUBSPOT_PRIVATE_APP_TOKEN is not configured.", }); } else { registrations.push({ connector: createHubSpotConnector(), metadata: HubSpotMetadata, connectorIdentity: { connectorId: "hubspot", publicIdentity: "spiffe://parmana/connectors/hubspot", authenticationMetadata: {}, }, credentialProvider: hubspotCredentialProvider, policy: new DefaultConnectorPolicy(authenticator, sessions), gatewayAuthentication, crypto, audit, }); } ``` Adding a built in connector to the running server is more than one file. Slack, the most recent, touches these (checked against the source on 2026-10-02), and a new connector needs the same: | Where | What | | -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `packages/connector-/` | The client for the target system | | `packages/execution-gateway/src/connector-execution/GatewayAdapter.ts`, `createGatewayConnector.ts`, both `index.ts` | The gateway side that receives verified, frozen content and calls the client | | `packages/api/src/bootstrap/createConnector.ts`, `createCredentialProvider.ts` | Build the connector; resolve its credential (return `undefined` when it is not configured) | | `packages/api/src/bootstrap/createConnectorRegistry.ts` | Register it, following the shape above | | `packages/api/src/bootstrap/createConnectorAuthenticator.ts` | Trust its identity | | `packages/api/src/bootstrap/ConnectorCatalog.ts`, `packages/connector-sdk/src/ConnectorFactory.ts` | List it | | `packages/capability-registry/src/CapabilityPolicyBinding.ts` | Bind its capability to one policy name; `assertConnectorCapabilitiesBound` refuses to start with an unbound one | | `policies///policy.json` | The policy, with `approvalSignals`, then approved through [policy governance](/guides/policy-lifecycle-and-approvals) | | `packages/api/src/application.ts` and a `create...SignalVerifier.ts` (optional) | Check a fact against the target system before release, as Slack does for its channel | | `tests/architecture/execution-boundary.test.ts`, `sandbox-connector-variables.test.ts` | List the new connector class; add its variable prefix, and the variables to the guard in `createSandboxOptions.ts`, so the sandbox refuses them | **Not** `createConnectorRoute.ts`, despite its name: that file's `route()` function is dead code in the current production wiring (`createExecutionGateway.ts` always supplies `executionControl.service`, which makes the only branch that calls `route()` unreachable); editing it has no effect on which connector actually handles a request. There is no dynamic registration path for a built in connector and no environment variable that adds one. To connect a system with no code change, register it as an [external connector](/guides/connect-any-external-system) instead. HubSpot, GitHub, Paytm and Slack are the built in connectors, see [HubSpot](/integrations/hubspot) for that connector specifically. ## The pattern, proven twice: Razorpay and HubSpot Razorpay was deliberately removed from the codebase in full. The lessons below are kept as a historical, worked example (a second real connector, built after HubSpot's own lessons already existed to apply), not a claim that Razorpay is still registered anywhere in this codebase today. Both connectors implemented everything above, but the two builds themselves are the more useful lesson: Razorpay learned each point below the hard way, on a running connector, after the fact. HubSpot applied all of it from its first version instead. Read this before starting a new connector. **Scope to one narrow action first.** Don't build a connector for a whole external API surface. Razorpay started with refund creation only, not payouts, not subscriptions, not the rest of the payments API. HubSpot started with one property update on one object type, a Deal's `dealstage`, optionally alongside `amount`, not Contacts, not Companies, not delete. Widen scope in a later milestone, once the first one is fully proven, see [HubSpot](/integrations/hubspot)'s own [ROADMAP] section on its page. **Two structural guards belong in a connector's first version, not a follow-up fix.** - _Refuse a placeholder test credential against the real endpoint, before any network call._ Don't rely on the vendor happening to reject it, that's an accident of their behavior, not something this codebase controls. `RazorpayConnector` didn't have this check originally, it survived only because Razorpay's real API happened to reject an unrecognized key — a defense-in-depth fix was added after the fact. The HubSpot connector refuses its own placeholder token against `https://api.hubapi.com` from its first commit, see [HubSpot](/integrations/hubspot) and `packages/execution-gateway/src/connector-execution/GatewayHubSpotAdapter.ts`. - _Read the documented test-credential environment variable name directly, never through a bridge variable._ `createRazorpayCredentialProvider.ts`'s test-mode branch originally read a word-order-swapped bridge variable instead of the name `.env.example` actually documented, fixed only after it drifted out of sync. `createHubSpotCredentialProvider.ts` reads `TEST_HUBSPOT_PRIVATE_APP_TOKEN`, the exact documented name, with no bridge to get wrong. **Apply `boundSignals` hardening from the policy's first version, don't wait for an adversarial-testing session to find the gap.** `razorpay-refund/1.0.0` didn't declare `boundSignals` until a live demonstration showed a caller could declare small, verified signals while `intent` executed something else, closed in an adversarial-testing hardening session. `hubspot-deal-update/1.0.0` declares `boundSignals` binding `proposedDealStage`/`proposedAmount` to their `intent.parameters` fields from its first commit, closing the same class of gap before any live demonstration of it existed. See [Policies and the decision](/concepts/policies-and-the-decision). **Require a signed human approval in every approve rule, reads included.** No AI agent action is authorized without one. `PolicyValidator` refuses to load a policy whose approve rule does not require a fact from `approvalSignals` with `is_true` at its top level, so a new connector's policy must declare one, with `resourceId` naming what the approval covers (the Intent's `target`, or a path such as `parameters.dealId`) and, for an amount, `value`. Give a read its own policy, like `github-pr-read` and `hubspot-deal-read`, so its approval names only what is read. See [Human approval](/concepts/human-approval). **Test order, every time:** 1. **Hermetic first.** The full authorize → verify → execute → confirm chain against a mock server, zero network calls. Both connectors' unit suites run this way on every `npm test`. 2. **Policy-denial-makes-zero-calls, proven at two layers.** A direct state check against the mock server (nothing changed), and a `fetch` spy at the HTTP boundary (literally zero calls reached the mock or real endpoint). See [HubSpot](/integrations/hubspot) for what that looks like in a captured response. 3. **A gated live suite last**, behind an `ALLOW_LIVE_=1` flag checked alongside a real test credential, skipped by default so a routine `npm test` never makes a live call by accident. **Prefer a non-destructive live-test action where the target system allows one.** Read live, apply a small reversible change, verify independently, revert, in the same run. HubSpot's live suite nudges a real test deal's `amount` by a small within-threshold delta and reverts it before finishing, safe to rerun indefinitely. Razorpay's refund is irreversible by construction, its captured test payment's refundable remainder depletes by a fixed amount on every live run — a real operational cost of testing against a real endpoint. **Disclose what actually happened, including when the mechanism catches your own mistake.** HubSpot's first live amount-change test run failed with a policy denial the test author didn't expect, because the test's own signals payload omitted `proposedAmount`, `SignalIntentBinder` correctly rejected the mismatch before the intended amount-threshold rule ever ran. That's not a connector defect, it's the binding working exactly as designed, against a test's own mistake this time instead of a caller's. ## Troubleshoot - **`does not declare capability "..."` thrown by `MockConnector` or your own connector.** `request.capability` didn't match anything in `connectorCapabilities([...])`. Check for a typo, capability strings are exact, case-sensitive matches. - **`SdkConnectorExecutor` rejects with a version mismatch.** An `expectedVersion` was configured at registration and your connector's `metadata.version` doesn't match, this guards a rolling deployment that swapped connector builds underneath a pinned expectation. - **Registered your connector but `POST /execute` still answers `503 CONNECTOR_NOT_REGISTERED`.** Registration in a `ConnectorSdkRegistry` you constructed yourself doesn't reach the running default server, that server builds its own registry in `createConnectorRegistry.ts`, see the Warning above. ## Next What your connector's `context.credential` actually is, and its lifecycle. The concept this guide's credential handling exercises. --- ## Write Your First Policy Source: https://docs.parmanasystems.com/guides/write-your-first-policy **[AVAILABLE]**, every command below runs against the repo's actual code. ## Goal Write a new Policy from scratch, distinct from the shipped `vendor-payment` one, and confirm it approves and rejects correctly. ## Prerequisites - The repo cloned and `npm install` run. - Read [Policies and the decision](/concepts/policies-and-the-decision) first if you haven't, this guide assumes you know what first-match-wins and fail-closed mean. ## Steps ### 1. Write the policy document A Policy is a JSON file at `///policy.json`. This exact policy already ships at `examples/tutorials/14-custom-policy/policies/high-value-payment/1.0.0/policy.json` (step 3 below runs it directly, no file creation needed to follow along). Its core, the two rules this walkthrough exercises: ```json { "policyId": "high-value-payment", "policyVersion": "1.0.0", "schemaVersion": "1.0.0", "description": "Authorizes high-value vendor payments requiring Finance Director approval.", "signalsSchema": { "vendorVerified": "boolean", "invoiceVerified": "boolean", "paymentApproved": "boolean", "sufficientFunds": "boolean", "paymentAmount": "number", "riskScore": "number", "financeDirectorApproved": "boolean" }, "boundSignals": { "paymentAmount": "parameters.amount" }, "unboundSignalReasons": { "vendorVerified": "Independently attested by upstream vendor verification; no Intent-side equivalent field exists to bind against.", "invoiceVerified": "Result of an independent invoice-verification check; not a value the Intent itself declares.", "paymentApproved": "Attested by a separate payment-approval step; the Intent carries no approval-status field to bind against.", "sufficientFunds": "Result of an independent funds-availability check against account state; not expressible via the Intent's target/parameters.", "riskScore": "A computed payment-risk score, not a value the caller's Intent declares or could be checked against." }, "approvalSignals": { "financeDirectorApproved": { "resourceId": "target", "value": "parameters.amount" } }, "rules": [ { "id": "approve-high-value-payment", "condition": { "all": [ { "fact": "vendorVerified", "operator": "eq", "value": true }, { "fact": "invoiceVerified", "operator": "eq", "value": true }, { "fact": "paymentApproved", "operator": "eq", "value": true }, { "fact": "sufficientFunds", "operator": "eq", "value": true }, { "fact": "paymentAmount", "operator": "gt", "value": 10000 }, { "fact": "financeDirectorApproved", "operator": "is_true" }, { "fact": "riskScore", "operator": "lte", "value": 20 } ] }, "outcome": { "action": "approve", "reason": "High-value vendor payment authorized after Finance Director approval." } }, { "id": "reject-high-value-without-director-approval", "condition": { "all": [ { "fact": "paymentAmount", "operator": "gt", "value": 10000 }, { "fact": "financeDirectorApproved", "operator": "eq", "value": false } ] }, "outcome": { "action": "reject", "reason": "High-value vendor payment rejected because Finance Director approval is required." } }, { "id": "reject-default", "condition": { "always": true }, "outcome": { "action": "reject", "reason": "One or more required policy conditions were not satisfied." } } ] } ``` `financeDirectorApproved` is declared in `approvalSignals`, so it is true only with a signed approval from a trusted approver for this vendor (the Intent's `target`), covering this amount. Every approve rule must require such a signal with `is_true` at its top level: no agent action is authorized without a signed human approval, and `PolicyValidator` refuses to load a policy that breaks this ([Human approval](/concepts/human-approval)). The real file has more rules than shown above: a lower-value approve path (`paymentAmount` at or below 10000, still with the director's signed approval), and individual rejection rules for an unverified vendor, unverified invoice, unapproved payment, insufficient funds, an invalid amount, and high risk score, each with its own specific reason string, plus the trailing `reject-default` catch-all shown above. Without that catch-all, an unmatched transaction still rejects (fail-closed is the engine's default, not something the policy has to opt into), but writing it explicitly documents the reject-by-default behavior for anyone reading the policy file itself. `PolicyValidator.validate()` fails closed on any rule-referenced fact that is neither in `boundSignals` nor `unboundSignalReasons`, an uncovered, unacknowledged fact throws `PolicyValidationError` and the policy never loads at all, it does not merely warn. This policy has one fact with a genuine Intent-side equivalent, `paymentAmount` (an amount, bound to `parameters.amount` above), so it belongs in `boundSignals`, where `SignalIntentBinder` checks it before evaluation runs rather than trusting a caller's declaration unchecked. `financeDirectorApproved` is covered by its `approvalSignals` entry. Every other fact here (`vendorVerified`, `invoiceVerified`, `paymentApproved`, `sufficientFunds`, `riskScore`) is an independently attested boolean or score with no Intent-side equivalent, so each needs its own `unboundSignalReasons` entry explaining why, not silence. See [Policies and the decision](/concepts/policies-and-the-decision) for the mechanism and why it exists: failing closed here, instead of an easily-missed load-time warning, is what makes an uncovered fact impossible to ship by accident. ### 2. Reference it from a Business Transaction ```json { "policy": { "name": "high-value-payment", "version": "1.0.0", "schemaVersion": "1.0.0" }, "signals": { "vendorVerified": true, "invoiceVerified": true, "paymentApproved": true, "sufficientFunds": true, "paymentAmount": 25000, "riskScore": 10, "financeDirectorApproved": true, "approvalArtifact": { "payload": { "...": "..." }, "signature": { "...": "..." } } } } ``` `approvalArtifact` is the Finance Director's signed approval for this vendor, up to 25000. Without it, `financeDirectorApproved: true` is refused. ### 3. Evaluate it This exact scenario already exists as `examples/tutorials/14-custom-policy/`, reuse it rather than writing a parallel harness: it builds a `FilePolicyRepository` pointed at a local `policies/` directory and executes a transaction through `RuntimeFactory.create()` directly, no server required. A demo Finance Director, a key made in memory (`examples/shared/helpers/demo-approval.ts`), signs the approval and is the only approver the tutorial trusts. ```bash node_modules/.bin/tsx examples/tutorials/14-custom-policy/run.ts ``` Before the transaction result, you'll see several `policy_rule_conflict_detected` log lines. These are `PolicyValidator.findRuleConflicts()`'s advisory output, harmless, not a failure: this policy's extra rejection rules share some overlapping conditions the conflict checker can't fully reason about, so it flags them for manual review rather than guessing. See [Policies and the decision](/concepts/policies-and-the-decision) for what this check does and doesn't catch. ## Verify Real output, from a run on 2026-10-02 (the hash differs on every run, because it covers timestamps and ids): ```json "decision": { "outcome": "APPROVED", "reason": "High-value vendor payment authorized after Finance Director approval.", "policy": { "name": "high-value-payment", "version": "1.0.0", "schemaVersion": "1.0.0" } }, "status": "COMPLETED" ``` ``` Verification: { "status": "VERIFIED", "trustRecordHash": "0d0dbc6bf76e3bb30f44792e9de59b6e183ed12b4080f0587784a4831b293a70" } ``` Now confirm the reject path. The tutorial always attaches the Finance Director's signed approval (`withDemoApproval` sets `financeDirectorApproved` to true with a matching approval), so change a fact the approval does not cover. In a scratch copy of the tutorial folder, set `"vendorVerified": false` in `transaction.json`, and rerun. This is what actually happens (verified against a real run on 2026-10-02): ``` RuntimeError: Execution rejected: Vendor payment rejected because the vendor has not been verified. at ExecutionGate.enforce (packages/runtime/src/ExecutionGate.ts:11) ``` A `REJECTED` decision does not come back as a normal object with `outcome: "REJECTED"`, `RuntimeFactory`'s `ExecutionTrustApplication.execute()` throws a `RuntimeError` whose message is the matched rule's rejection reason. `examples/tutorials/14-custom-policy/run.ts` doesn't catch this specially, so an uncaught rejection crashes the script, that's expected, not a bug in the tutorial, a caller that wants to handle rejection gracefully needs a `try`/`catch` around `execute()`. The `reject-unverified-vendor` rule is what matched, confirmed by the message: first match wins, on different signals, same policy document, no code change. Over HTTP the same rejection is `403 POLICY_DENIED` with this reason, and a signed Refusal Record is stored. The `reject-high-value-without-director-approval` rule matches when a caller sends `financeDirectorApproved: false`; `true` without a valid signed approval is refused by the approval check instead (`financeDirectorApproved=true != verified financeDirectorApproved=false`). ## Why explicit rejection rules? (Auditability over simplicity) Parmana policies mix approval and rejection rules in one ordered array, exactly like `high-value-payment` above. That's a deliberate choice, not an oversight, some architectures prefer "approval-only + implicit default-reject" instead, and it's worth being explicit about why this codebase doesn't. **The engine is fail-closed by default, independent of how you write your policy.** `PolicyEngine.evaluate()`'s outcome-mapping switch (`PolicyEngine.ts`) treats any unmatched or unrecognized action as `REJECT`, this is the engine's own default branch, not something a policy author opts into. You don't have to remember a catch-all rule for the engine to fail closed; it already does. **So why write explicit rejection rules at all?** Because the audit trail records a specific, human-readable reason for every rejection, _"the vendor has not been verified"_, not a generic _"no rule matched."_ For a system whose refusals are independently, cryptographically verifiable (see [Refusal records](/concepts/refusal-records)), a specific reason is the whole point: compliance, debugging, and customer communication all need to know _why_, not just _that_. **First-match-wins means rule order matters.** `PolicyEngine.findFirstMatch()` evaluates rules in the order they appear in the JSON file and returns on the first one whose condition is true, there is no priority system beyond array order. This is a real source of bugs if two rules' conditions can both be true for the same input: whichever is listed first silently wins, with nothing surfacing that the second rule is partly or wholly shadowed. `PolicyValidator.findRuleConflicts(policy)` checks for exactly this, pairs of rules whose conditions can be true simultaneously, and `PolicyRouter.load()` logs a `policy_rule_conflict_detected` warning naming them. It is **advisory, not fail-closed** (unlike `boundSignals` coverage): a flagged pair might be a genuine bug, or might be an intentional priority ordering between two violation reasons that can legitimately co-occur (this repo's own `hubspot-deal-update` policy has exactly one such case). It also only proves `NO_OVERLAP` for condition shapes it can fully reason about (same-fact `eq`/`neq`/numeric comparisons, and `all` conjunctions where at least one conjunct is provably disjoint from the other side), anything more complex is reported as `NEEDS_REVIEW`, an honest "can't tell," never a guessed answer. ## Troubleshoot - **Decision comes back `REJECTED` with `"reason": "One or more required policy conditions were not satisfied."`, not the reason you expected.** Your transaction's signals matched no earlier rule, only the trailing default. Check `signalsSchema` against what you actually sent, a typo'd signal name is silently `undefined`, which fails every `eq`/`gt`/`lte` condition. - **Decision comes back `REJECTED` with `"matchedRuleId": "signal-type-violation"`.** A signal was sent with a type other than the one `signalsSchema` declares, for example an amount as `"500"` instead of `500`. The reason names each signal and the type it must have. - **`PolicyValidator` throws about policy identity.** Your Business Transaction's `policy.name`/`policy.version` doesn't match the loaded file's `policyId`/`policyVersion` exactly, these are checked, not inferred from the file path. - **Wrong policy loaded, or "policy not found."** `FilePolicyRepository` resolves `///policy.json` literally, confirm the directory structure matches, including the version folder. ## Next What happens after a policy approves: signing, gateway verification, execution. The concept this guide exercises. --- ## Policy lifecycle and approvals Source: https://docs.parmanasystems.com/guides/policy-lifecycle-and-approvals A policy authorizes nothing until one person proposes it and a different person approves it with a signed step up authorization. Once approved, it is the version in effect for its action, with no deploy. Separately, a policy can say that some actions also need a named person's signed approval each time they run. This page covers both, in the order you do them. ## Who does what | Role | What they do | What they hold | | ------------------ | ------------------------------------- | --------------------------------------------------------------------- | | Author | Writes the policy file | A clone of the repository | | Proposer (maker) | Sends the policy for approval | An API key added as a human (`credentialHolderType USER`) | | Approver (checker) | Reviews and approves or rejects it | Their own API key added as a human, and their own step up private key | | Operator | Adds API keys, approver keys, deploys | Access to the deployment | | Agent | Sends requests that name the policy | A service API key | | Action approver | Signs off on one critical action | Their own approval private key | The proposer and the approver must be two different people. The server refuses an approval from the proposer's API key, but it cannot tell whether two keys belong to two people: keep each key on its owner's machine. ## Part 1: from a policy file to a policy in effect ```mermaid sequenceDiagram participant A as Author participant P as Proposer participant S as Parmana participant R as Approver participant G as Agent A->>A: Write policies/name/version/policy.json P->>S: POST /policies/name/version/pending-changes S-->>P: 201, pendingPolicyChangeId, PENDING_APPROVAL R->>S: GET /policies/pending-changes?status=PENDING_APPROVAL R->>R: Sign step up authorization (own key, 120 s) R->>S: POST /policies/pending-changes/id/approve S->>S: Check, sign approval record, save policy S-->>R: 200, APPROVED G->>S: POST /execute naming name at version S->>S: Version in effect? Approval record valid? Evaluate ``` ### 1. Write the policy A policy is a JSON file at `policies///policy.json`: `policyId`, `policyVersion`, `schemaVersion`, and ordered `rules`, where the first matching rule decides. See [Write your first policy](/guides/write-your-first-policy) for rules and operators. Three optional sections tie signals to reality: - `boundSignals`: a signal must equal a field of the request, such as `"refundAmount": "parameters.amount"`. - `unboundSignalReasons`: why a signal the rules read is not bound. Every fact a rule reads must be bound, declared as an approval signal, or given a reason here, or the policy is refused. - `approvalSignals`: signals that count as `true` only with a person's signed approval (Part 2). A new version of a live policy is a new folder, such as `customer-refund/1.2.0`, never an edit of an approved one. Check it before proposing: `npx vitest run packages/policy/tests/unit/ReferencePolicies.test.ts` validates every policy file under `policies/`. ### 2. Set up the people, once - **Proposer and approver API keys**, each added as a human. On a self hosted deployment: [Manage API keys](/self-hosted/api-keys). Anything else gets `403 NON_HUMAN_CALLER_DENIED` on every governance call. - **The approver's step up key**, made on the approver's own machine. Only the public half goes to the operator: ```bash openssl genpkey -algorithm ed25519 -out step-up.private.pem openssl pkey -in step-up.private.pem -pubout -out step-up.public.pem ``` The operator registers `step-up.public.pem` on the approver's API key and deploys. ### 3. Propose The request carries the whole policy and a reason. The name and version in the URL must match `policyId` and `policyVersion` in the content, and the content must pass validation. ```bash Bash export PARMANA_URL=https://your-parmana-host export PROPOSER_KEY= printf '{"reason":"Why this should take effect.","proposedContent":%s}' \ "$(cat policies/customer-refund/1.2.0/policy.json)" > proposal.json curl -s -X POST $PARMANA_URL/policies/customer-refund/1.2.0/pending-changes \ -H "Authorization: Bearer $PROPOSER_KEY" -H "Content-Type: application/json" \ --data @proposal.json ``` ```powershell PowerShell $url = "https://your-parmana-host" $key = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR((Read-Host "Proposer API key" -AsSecureString))) # Read as UTF-8: Windows PowerShell 5.1 Get-Content uses the local codepage and changes characters such as an em dash. $policy = [System.IO.File]::ReadAllText("$PWD\policies\customer-refund\1.2.0\policy.json", [System.Text.Encoding]::UTF8) $body = '{"reason":"Why this should take effect.","proposedContent":' + $policy + '}' $r = Invoke-RestMethod -Method Post -Uri "$url/policies/customer-refund/1.2.0/pending-changes" ` -Headers @{ Authorization = "Bearer $key" } -ContentType "application/json" ` -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) $r.pendingPolicyChangeId ``` The response is `201` with a `pendingPolicyChangeId` and status `PENDING_APPROVAL`. Only one open proposal per policy version is allowed; a second gets `409 CONFLICT`. Send the id to the approver. Send the file's content unchanged. The approval stores a hash of the approved policy content (`hashPolicyContent`), and CI's `verify-policy-approvals` job (`scripts/verify-policy-changes-approved.ts`) compares it with the hash of the file in git; the server makes the same comparison at startup (`verifyPolicyGovernanceIntegrityAtStartup.ts`). If any value differs, even one character inside a description, the check fails. A version that is already approved can be proposed and approved again; the most recent approval is the one that counts, both for CI and for the server. ### 4. Review The approver lists what is waiting and reads the proposed content before signing: ```bash curl -s "$PARMANA_URL/policies/pending-changes?status=PENDING_APPROVAL" \ -H "Authorization: Bearer $APPROVER_KEY" ``` ### 5. Approve or reject The approver signs a step up authorization for this one change and this one action, on their own machine, then sends it within its lifetime (120 seconds at most). The SDKs sign too: `signPolicyChangeStepUp()` in TypeScript, `sign_policy_change_step_up()` in Python. ```bash Bash export CHANGE_ID= npx tsx scripts/sign-policy-change-step-up.ts \ --private-key-file step-up.private.pem --key-id \ --pending-policy-change-id "$CHANGE_ID" --action approve > signed.txt curl -s -X POST $PARMANA_URL/policies/pending-changes/$CHANGE_ID/approve \ -H "Authorization: Bearer $APPROVER_KEY" -H "Content-Type: application/json" \ -d "{\"stepUpAuthorization\":$(grep '^{' signed.txt)}" ``` ```powershell PowerShell $changeId = "" $stepUp = npx tsx scripts/sign-policy-change-step-up.ts --private-key-file step-up.private.pem ` --key-id --pending-policy-change-id $changeId --action approve | Where-Object { $_ -like '{*' } $body = '{"stepUpAuthorization":' + $stepUp + '}' Invoke-RestMethod -Method Post -Uri "$url/policies/pending-changes/$changeId/approve" ` -Headers @{ Authorization = "Bearer $key" } -ContentType "application/json" ` -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) ``` To reject, sign with `--action reject` and post to `.../reject` with a `rejectionReason`. On approve, the server does this, in order, and stops at the first failure: 1. The caller is a verified human, or `403 NON_HUMAN_CALLER_DENIED`. 2. The change exists, or `404 PENDING_POLICY_CHANGE_NOT_FOUND`. 3. The caller is not the proposer, or `403 SAME_ACTOR_CANNOT_APPROVE_OWN_CHANGE`. 4. The step up authorization is valid for this change and action, signed by the key registered for this caller, not expired, not used before, or `403 STEP_UP_AUTHORIZATION_INVALID`. 5. It writes a signed approval record: who proposed, who approved, when, the content hash before and after, and the hash of the previous record for this policy version, so records form a chain. 6. It saves the policy content and marks the change `APPROVED`. ### 6. It takes effect For an action bound to a policy (such as `paytm:refund` to `customer-refund`), the version in effect is the one with the most recent approval, so the approval is the release: no deploy. From then on: - Agents must name that version. A request naming any other version, including an older one that was approved before, is refused before any rule runs, with a message naming the version in effect. - To roll back, approve the older version again. The newest approval wins. Binding a built in action to a different policy name still needs a code change and a deploy. A new action for a system Parmana has no connector for needs neither: register it as an external connector, with its policy, through maker checker. See [Connect any external system](/guides/connect-any-external-system). ### 7. What is checked on every request - The version named is the version in effect for the action. - The loaded policy has an approval record, the record's signature verifies, and its content hash equals the live policy's. Anything else refuses the request, so a policy edited in the database outside this flow cannot authorize anything. - The gateway checks the approval record again just before release. - At startup, the server checks every policy against its latest approval record and logs the result. These checks are on whenever `NODE_ENV` is anything other than `test` or `development`, and nothing switches them off in production. In `test` or `development` they are off unless `POLICY_EXECUTION_VERIFICATION_ENFORCED=true`. So approve every policy a deployment executes against **before** promoting it; a policy still `PENDING_APPROVAL`, or rejected with no approved replacement, is refused on the first request. ### 8. Check what is approved `content_hash_after` is a hash of the canonical JSON (keys sorted), not of the file bytes, so a plain file hash will not match. Use the script that compares every policy on disk with its latest approval record: ```bash npx tsx scripts/verify-policy-changes-approved.ts --full-scan ``` The CI job `verify-policy-approvals` runs the same full scan on every push and pull request, and is a required check on `main`, so a pull request that adds a policy version merges only after that version is approved in production. A rejection writes no approval record: the change's `rejectionReason` is the record it leaves. ### Sign and submit in one step Signing and sending by hand leaves a gap in which the 120 second step up lifetime can run out. `scripts/local-review-action.ts` signs and submits in one process, on the approver's own machine: ```powershell $env:REVIEWER_KEY = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR((Read-Host "Approver API key" -AsSecureString))) npx tsx scripts/local-review-action.ts --pending-policy-change-id --action approve ` --private-key-file --key-id ` --api-base https://your-parmana-host ``` Always pass `--private-key-file`, `--key-id` and `--api-base`: the defaults name a local file, a caller id and a host from this project's own setup. The script prints the HTTP status and response only, never a key. `scripts/refresh-approved-policy-content.ts` proposes the file on disk and approves it in one run, so it needs both the proposer's and the approver's keys in one process. That is one person acting as both; use it only in a test environment, never where the approval record must stand for a second person. ## Part 2: a person signs off on one action Some actions need a person each time, such as a refund above 10000. The policy declares which signal needs a signed approval, and where the request says what the approval is for: ```json "approvalSignals": { "managerApproved": { "resourceId": "parameters.orderId", "value": "parameters.amount" } } ``` `resourceId` is `"target"` or a path into `parameters`; `value` is optional and must point at a number. Without `value`, the approval names exactly one resource, which suits a merge approval (`"resourceId": "target"`, for `acme/api#42`). ```mermaid sequenceDiagram participant G as Agent participant S as Parmana participant M as Manager G->>S: Refund 75000, managerApproved false S-->>G: 403, amounts above 10000 need a signed manager approval M->>S: Finds the refusal (Refusal Records) M->>M: sign-approval.ts: this order, up to 75000, 15 min, once M-->>G: approval.json G->>S: New request, managerApproved true, approvalArtifact S->>S: Verify before authorizing, use it once S->>S: Gateway verifies again before release S-->>G: 200, refund executed, signed record ``` ### Set up an action approver, once ```bash npx tsx scripts/generate-approver-key.ts \ --approver-id manager-priya --key-id manager-priya-key-1 --out-dir ~/.parmana ``` The private key stays on the manager's machine. The public key is trusted through maker checker, with no deploy: one person proposes adding it and a different person approves it ([Manage approvers](/guides/manage-approvers)). Revoking it is the same flow. ### Sign an approval ```bash npx tsx scripts/sign-approval.ts \ --private-key-file ~/.parmana/manager-priya__manager-priya-key-1.private.pem \ --approver-id manager-priya --key-id manager-priya-key-1 \ --capability paytm:refund --resource-id ORD-1042 --max-amount 75000 --out approval.json ``` The agent sends a new request with the approval signal set to `true` and the contents of `approval.json` in `signals.approvalArtifact`. Parmana checks the approver is trusted and not revoked, the Ed25519 signature, the expiry, the action, the resource and the amount taken from the request, never from the agent's signals, and that the approval was not used before. It checks again at the gateway just before release. Without a valid approval the request is refused: ```text Execution rejected: Rejected: declared signal(s) do not match independently verified state (managerApproved=true != verified managerApproved=false). ``` To find refusals waiting for a manager, query the Refusal Records for `matchedRuleId = 'reject-manager-approval-required'` (see [Human approval](/concepts/human-approval#review-refused-requests)). To see the whole flow run, without a server or AWS, run tutorial 119: `npx tsx examples/tutorials/119-human-approval-for-one-action/run.ts`. It uses the real `customer-refund` 1.2.0 policy and shows a small refund refused without a manager, a refusal, a refused unsigned claim, a signed approval accepted and checked again before release, and an approval refused when reused, stretched to a larger amount or moved to another order. ## Where it is recorded | Table | Holds | | --------------------------------------- | ----------------------------------------------------------------------------------- | | `pending_policy_changes` | Every proposal, its status, who proposed and who resolved it | | `policy_change_approval_records` | Signed, chained approval records; the latest per name decides the version in effect | | `policies` | The approved policy content | | `consumed_policy_change_step_up_nonces` | Step up authorizations already used | | `consumed_approval_nonces` | Action approvals already used | | `refusal_records` | Every refused request, signed | ## Troubleshooting | You see | Cause | Fix | | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `401` `authentication required` | The API key is missing, wrong or rotated, or the header is not `Authorization: Bearer ` | Check the variable is set in this terminal (`$env:REVIEWER_KEY.Length` prints a number, not `0`). | | `403 STEP_UP_AUTHORIZATION_INVALID` | The signature expired (over 120 seconds), was already used, or was made with a private key that does not match the `stepUpPublicKey` registered for this caller | Sign again and send at once, or use `local-review-action.ts`. If it persists, check your private key matches the registered public key (after a rotation, replace the `.pem` too). | | `403 SAME_ACTOR_CANNOT_APPROVE_OWN_CHANGE` | The approving API key belongs to the proposer | A different person approves with their own key. There is no override. | | `403 NON_HUMAN_CALLER_DENIED` | The API key is not registered as a human (`credentialHolderType USER`) | The operator registers it as a human. | | `400` naming a field on propose | The body is malformed, `policyId` does not match the name in the URL, or the version has characters outside `A-Z a-z 0-9 . _ -` | Read the `error` message; it names the field. | | `400 Malformed JSON body` | The shell changed the quotes in an inline JSON body | Build the body in a variable or a file as shown above, not inline in the command. | | `409 CONFLICT` on propose | A proposal for this policy version is already open | Approve or reject the open one first. | | Approved content lacks fields the file has | Proposals are frozen snapshots. An old proposal approved later carries the old content | Reject stale proposals and propose again from the current file, then run the full scan above. | ## Good to know - A decision is final. A change moves from `PENDING_APPROVAL` to `APPROVED` or `REJECTED` once and never back; to change your mind, propose again. - The step up private key is never sent. Only the signed authorization it produces is. - With a database configured, approved content is written to the `policies` table through `SupabasePolicyRepository`, and requests read it from there. With `PARMANA_STORAGE=memory` it is written to the local file. - `packages/governance-ui` can propose, list and show a diff, but cannot approve or reject: a web page that collected a step up private key would break the rule that the key never leaves its owner's machine. - The code for this flow is small: `packages/api/src/routes/pending-policy-changes.ts`, `packages/api/src/governance/PolicyChangeApprovalService.ts` and `packages/api/src/auth/PolicyChangeStepUpVerifier.ts`. ## Keep it honest - Two people, two machines, two keys. A single person holding both API keys makes the record claim a second person who does not exist. - Never paste an API key into a chat or a ticket. `Read-Host -AsSecureString` in PowerShell keeps it off the screen. Rotate any key that was shown. - Approver private keys never leave their owner's machine; only public keys are shared. --- ## Manage approvers Source: https://docs.parmanasystems.com/guides/manage-approvers Every agent action needs a signed approval from a trusted approver key, reads included, such as a manager approving a refund or a reviewer approving a pull request read ([Human approval](/concepts/human-approval)). With no trusted approver, nothing an agent asks for runs. This guide shows how to trust a new approver key, rotate one, and revoke one, through the API. Each change is proposed by one person and approved by a different person with a step up signature, and it takes effect on the next request. Nothing is deployed. Approvers added this way are stored in the `approval_issuers` table. Approvers listed in the server code (`createApprovalIssuerRegistry.ts`) still work, and are shown by the same list call, but they change only with a pull request and a deploy. ## How it works ```mermaid sequenceDiagram participant A as New approver participant P as Proposer participant S as Parmana participant C as Checker participant G as Agent A->>A: Generate an Ed25519 key pair, keep the private key A->>P: Send the public key P->>S: POST /approval-issuers/changes (action add) S-->>P: 201, changeId, PENDING_APPROVAL C->>S: GET /approval-issuers/changes?status=PENDING_APPROVAL C->>C: Sign a step up authorization for changeId C->>S: POST /approval-issuers/changes/changeId/approve S-->>C: 200, APPROVED, the key is trusted A->>A: Sign an approval for one action G->>S: POST /execute with the approval S-->>G: 200, authorized ``` | Rule | What the server does | | --------------------------------------- | ------------------------------------------------------------------------------------- | | Only people change approvers | Every call needs an API key added as a human (`credentialHolderType USER`) | | The proposer is never the checker | `403 SAME_ACTOR_CANNOT_APPROVE_OWN_CHANGE`, in the API and in the database | | Approve and reject need fresh intent | A step up authorization for this change and this action, valid once, 120 seconds | | One open change per approver key | A second proposal for the same key is `409 CONFLICT` until the first is resolved | | Approving applies the change atomically | The key is added or revoked and the change resolved in one database transaction | | A change takes effect at once | The next approval checked reads the table; nothing is cached | | Nothing is deleted | A revoked key stays listed, so every approval it ever signed can still be explained | | Key ids are used once | A key id that exists, revoked or not, cannot be added again: rotate with a new key id | | Failures refuse | If the table cannot be read, the approver is unknown and the approval is refused | ## Before you begin You need three people, or at least three roles: - **The new approver**, who will sign approvals. They make their own key pair and never share the private key. - **A proposer** with an API key added as a human. - **A checker**, a different person, with an API key added as a human and a registered step up key. This is the same setup policy approvals use: see [Policy lifecycle and approvals](/guides/policy-lifecycle-and-approvals#2-set-up-the-people-once). The deployment needs the migration `20260929120000_add_approval_issuers.sql`. Self hosted deployments apply it on start. Otherwise run `npm run db:migrate -- apply` before deploying this version, as in [Production deployment, step 2](/deployment/production#step-2-create-the-database-schema). ## Step 1: The approver makes a key pair On the approver's own machine: ```bash npx tsx scripts/generate-approver-key.ts \ --approver-id manager-priya \ --key-id manager-priya-key-1 \ --out-dir ~/.parmana ``` This writes `manager-priya__manager-priya-key-1.private.pem`, which stays on that machine, and `manager-priya__manager-priya-key-1.public.pem`, which they send to the proposer. Any Ed25519 key works; `openssl genpkey -algorithm ed25519` makes one too. `approverId` and `keyId` are the names the approver will put in every approval they sign. Use letters, digits, `.`, `_` and `-`, at most 128 characters. Put a number in the key id, so a later key can have the next one. ## Step 2: Propose adding the key ```bash curl curl -X POST "$PARMANA_URL/approval-issuers/changes" \ -H "Authorization: Bearer $PROPOSER_KEY" \ -H "Content-Type: application/json" \ --data "$(jq -n --rawfile pem manager-priya__manager-priya-key-1.public.pem '{ action: "add", approverId: "manager-priya", keyId: "manager-priya-key-1", publicKeyPem: $pem, reason: "Priya approves refunds for the West region from October." }')" ``` ```typescript TypeScript import { readFileSync } from "node:fs"; import { ParmanaClient } from "@parmana/sdk"; const proposer = new ParmanaClient({ endpoint: process.env.PARMANA_URL!, apiKey: process.env.PROPOSER_KEY, }); const change = await proposer.proposeApproverChange({ action: "add", approverId: "manager-priya", keyId: "manager-priya-key-1", publicKeyPem: readFileSync( "manager-priya__manager-priya-key-1.public.pem", "utf8", ), reason: "Priya approves refunds for the West region from October.", }); console.log(change.changeId); // send this to the checker ``` ```python Python import os from pathlib import Path from parmana import ParmanaClient proposer = ParmanaClient(endpoint=os.environ["PARMANA_URL"], api_key=os.environ["PROPOSER_KEY"]) change = proposer.approvers.propose_add( approver_id="manager-priya", key_id="manager-priya-key-1", public_key_pem=Path("manager-priya__manager-priya-key-1.public.pem").read_text(), reason="Priya approves refunds for the West region from October.", ) print(change.change_id) # send this to the checker ``` The response is the change, waiting for a checker: ```json 201 Created { "changeId": "ba7c5827-5844-4069-94fc-9b438ef08f78", "action": "add", "approverId": "manager-priya", "keyId": "manager-priya-key-1", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAEDuWHf+bbRY7J/Mr0RVmAKYHH5CDijTWhdnISXWLD6Y=\n-----END PUBLIC KEY-----\n", "reason": "Priya approves refunds for the West region from October.", "proposedBy": "human-maker", "proposedAt": "2026-09-28T19:05:59.726Z", "status": "PENDING_APPROVAL" } ``` The server stores the key in canonical PEM. A key that is not Ed25519 is refused with `400`. ## Step 3: The checker reviews it ```bash curl curl "$PARMANA_URL/approval-issuers/changes?status=PENDING_APPROVAL" \ -H "Authorization: Bearer $CHECKER_KEY" ``` ```typescript TypeScript const pending = await checker.approverChanges("PENDING_APPROVAL"); ``` ```python Python pending = checker.approver_changes("PENDING_APPROVAL") ``` Check the approver and key id, the reason, and that the public key is the one the approver sent you, compared over a channel other than the one the proposer used. ## Step 4: The checker approves it The checker signs a step up authorization on their own machine, naming this change and the action, and sends it with the approval. It is the same step up authorization policy changes use: the change id goes in `pendingPolicyChangeId`. ```bash curl npx tsx scripts/sign-policy-change-step-up.ts \ --private-key-file ~/.parmana/step-up.private.pem \ --key-id checker-step-up-1 \ --pending-policy-change-id ba7c5827-5844-4069-94fc-9b438ef08f78 \ --action approve > step-up.json curl -X POST "$PARMANA_URL/approval-issuers/changes/ba7c5827-5844-4069-94fc-9b438ef08f78/approve" \ -H "Authorization: Bearer $CHECKER_KEY" \ -H "Content-Type: application/json" \ --data "{\"stepUpAuthorization\": $(cat step-up.json)}" ``` ```typescript TypeScript import { readFileSync } from "node:fs"; import { ParmanaClient, signPolicyChangeStepUp } from "@parmana/sdk"; const checker = new ParmanaClient({ endpoint: process.env.PARMANA_URL!, apiKey: process.env.CHECKER_KEY, }); const approved = await checker.approveApproverChange( changeId, signPolicyChangeStepUp({ pendingPolicyChangeId: changeId, action: "approve", privateKeyPem: readFileSync("step-up.private.pem", "utf8"), keyId: "checker-step-up-1", }), ); ``` ```python Python from pathlib import Path from parmana.crypto import sign_policy_change_step_up approved = checker.approve_approver_change( change_id, sign_policy_change_step_up( pending_policy_change_id=change_id, action="approve", private_key_pem=Path("step-up.private.pem").read_text(), key_id="checker-step-up-1", ), ) ``` ```json 200 OK { "changeId": "ba7c5827-5844-4069-94fc-9b438ef08f78", "action": "add", "approverId": "manager-priya", "keyId": "manager-priya-key-1", "status": "APPROVED", "proposedBy": "human-maker", "resolvedBy": "human-checker", "resolvedAt": "2026-09-28T19:05:59.738Z", "...": "..." } ``` From the next request on, approvals signed with `manager-priya-key-1` verify. ## Step 5: Confirm the key is trusted ```bash curl curl "$PARMANA_URL/approval-issuers" -H "Authorization: Bearer $CHECKER_KEY" ``` ```typescript TypeScript const approvers = await checker.approvers(); ``` ```python Python approvers = checker.list_approvers() ``` ```json 200 OK { "issuers": [ { "approverId": "manager-charak1987", "keyId": "manager-charak1987-key-1", "revoked": false, "source": "code", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\n..." }, { "approverId": "manager-priya", "keyId": "manager-priya-key-1", "revoked": false, "source": "governed", "addedByChangeId": "ba7c5827-5844-4069-94fc-9b438ef08f78", "addedAt": "2026-09-28T19:05:59.738Z", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\n..." } ] } ``` The approver can now sign approvals, with `scripts/sign-approval.ts`, `signApproval()` or `parmana.crypto.sign_approval()`. See [Human approval](/concepts/human-approval). ## Rotate a key Key ids are used once, so a rotation is two changes: 1. The approver makes a new key pair with the next key id, such as `manager-priya-key-2`. 2. Propose and approve adding it (Steps 2 to 4). 3. The approver signs new approvals with the new key. 4. Propose and approve revoking the old key (below). Approvals signed with the old key keep working until step 4. Approvals are valid for 15 minutes by default, so revoking a few minutes after the switch refuses none that are in use. ## Revoke a key Revoke when an approver leaves the role, or a private key may be exposed. Once the revocation is approved, every approval the key ever signed is refused, including ones not used yet. ```bash curl curl -X POST "$PARMANA_URL/approval-issuers/changes" \ -H "Authorization: Bearer $PROPOSER_KEY" \ -H "Content-Type: application/json" \ --data '{"action":"revoke","approverId":"manager-priya","keyId":"manager-priya-key-1","reason":"Priya moved to another team."}' ``` ```typescript TypeScript const revoke = await proposer.proposeApproverChange({ action: "revoke", approverId: "manager-priya", keyId: "manager-priya-key-1", reason: "Priya moved to another team.", }); ``` ```python Python revoke = proposer.approvers.propose_revoke( approver_id="manager-priya", key_id="manager-priya-key-1", reason="Priya moved to another team.", ) ``` A checker then approves it as in Step 4. Only keys added through approver changes can be revoked this way; a key in the server code is revoked by setting `revoked: true` there and deploying. A revocation needs a second person, like every change. If a private key is exposed and no checker can be reached, an operator with database access can revoke it directly. This skips maker checker, so the change row must name who did it and why: ```sql begin; insert into approval_issuer_changes (change_id, action, approver_id, key_id, reason, proposed_by, proposed_at, status, resolved_by, resolved_at) values ('emergency-2026-10-01-1', 'revoke', 'manager-priya', 'manager-priya-key-1', 'Emergency: private key exposed. Done at the database by the operator.', 'operator-a', now(), 'APPROVED', 'operator-b', now()); update approval_issuers set revoked = true, revoked_by_change_id = 'emergency-2026-10-01-1', revoked_at = now() where approver_id = 'manager-priya' and key_id = 'manager-priya-key-1' and revoked = false; commit; ``` ## Reject a change ```bash curl curl -X POST "$PARMANA_URL/approval-issuers/changes/$CHANGE_ID/reject" \ -H "Authorization: Bearer $CHECKER_KEY" \ -H "Content-Type: application/json" \ --data "{\"rejectionReason\": \"Priya stays on refunds until 31 October.\", \"stepUpAuthorization\": $(cat step-up-reject.json)}" ``` ```typescript TypeScript await checker.rejectApproverChange( changeId, "Priya stays on refunds until 31 October.", signPolicyChangeStepUp({ pendingPolicyChangeId: changeId, action: "reject", privateKeyPem, keyId, }), ); ``` ```python Python checker.reject_approver_change( change_id, "Priya stays on refunds until 31 October.", sign_policy_change_step_up( pending_policy_change_id=change_id, action="reject", private_key_pem=pem, key_id=key_id ), ) ``` A rejection changes no key. The change keeps its `rejectionReason`. ## Errors | Status | Code | When | What to do | | ------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | 400 | none | `action` is not `add` or `revoke`, an id has other characters, `reason` is missing, or `publicKeyPem` is not an Ed25519 key | Fix the request. The message names the field. | | 401 | `UNAUTHORIZED` | No API key, or an unknown one | Send `Authorization: Bearer `. | | 403 | `NON_HUMAN_CALLER_DENIED` | The API key is not added as a human | Use a person's key, not an agent's. | | 403 | `SAME_ACTOR_CANNOT_APPROVE_OWN_CHANGE` | The checker is the proposer | A different person approves. | | 403 | `STEP_UP_AUTHORIZATION_INVALID` | No step up, or one that is expired, reused, for another change or action, or signed with a key not registered to this checker | Sign a new one for this change id and action, and send it within 2 minutes. | | 404 | `APPROVAL_ISSUER_CHANGE_NOT_FOUND` | No change has this id | Check the id from the proposal. | | 409 | `CONFLICT` | The key is in code, already exists, is not active (revoke), another change for it is pending, or the change is resolved | Read the message. Use a new key id to add a key again. | ## Reference - API: [List approver keys](/api-reference/endpoints/list-approval-issuers), [Propose](/api-reference/endpoints/propose-approval-issuer-change), [List changes](/api-reference/endpoints/list-approval-issuer-changes), [Approve](/api-reference/endpoints/approve-approval-issuer-change), [Reject](/api-reference/endpoints/reject-approval-issuer-change). - Tables: [`approval_issuers` and `approval_issuer_changes`](/storage/schema-reference#approval_issuers-and-approval_issuer_changes). - The server logs `approval_issuer_change_approved` for every approved change, and `approval_issuer_lookup_failed` when the table could not be read during a check. --- ## Approval notifications Source: https://docs.parmanasystems.com/guides/approval-notifications When an agent sends a request that a policy authorizes only with a signed approval, such as a refund that needs a manager, Parmana refuses it and the agent must send a new request with the approval attached. Approval notifications tell the approver right away, with exactly what they need to sign, in one or both of two ways: | Channel | You set | Best for | | ------- | ------------------------------------------------- | -------------------------------------------------------------------------- | | Email | `APPROVAL_EMAIL_TO`, and the Resend integration | A person's inbox, with nothing to host. [Set up email](#email). | | Webhook | `APPROVAL_WEBHOOK_URL`, `APPROVAL_WEBHOOK_SECRET` | Your own system: Slack, a ticket, a queue. [Set up the webhook](#webhook). | Both carry the same facts and are sent under the same rule, below. ```mermaid sequenceDiagram participant G as Agent participant S as Parmana participant W as Your endpoint participant M as Approver G->>S: POST /execute (refund, no approval) S->>S: Refuse. Would an approval authorize it? Yes S->>W: POST approval.needed (signed) S-->>G: 403 POLICY_DENIED W->>W: Verify the signature W->>M: Slack, email, ticket: "Refund ORD-1042, 75000, needs you" M->>M: Sign an approval for ORD-1042 up to 75000 M->>G: The approval G->>S: POST /execute (same refund, approval attached) S-->>G: 200, authorized ``` ## When an event is sent Parmana sends one event when all of these hold: 1. The request was refused by the policy's own rules, or because an attached approval did not verify. 2. The policy declares `approvalSignals`. 3. Evaluating the same policy, with every declared approval signal set to `true`, authorizes the request. The third condition means an event only goes out when an approval would actually help. A refund refused for a failed fraud check, or above the policy's maximum, sends nothing, because a manager's signature would not change the outcome. Requests refused before the policy runs, such as a bad API key or a request whose signals do not match its own parameters, send nothing. ## Email Parmana emails each event through [Resend](https://resend.com), from a domain you own. From the repository, linked to the project with `npx vercel link`: ```bash npx vercel integration add resend/resend-email --environment production \ -m domain=notify.example.com -m region=us-east-1 ``` Accept Resend's terms in the browser when asked. This sets `RESEND_API_KEY` and `RESEND_EMAIL_DOMAIN` on the project. Use a subdomain, such as `notify.example.com`, so its records cannot affect other mail on your domain. Resend lists three DNS records for the domain: a DKIM `TXT` record at `resend._domainkey.notify`, and an `MX` and a `TXT` (SPF) record at `send.notify`. Add them at your DNS provider. Emails are refused until the domain shows **Verified** in Resend. ```bash npx vercel env add APPROVAL_EMAIL_TO production ``` One address, or several separated by commas. Then redeploy. Emails come from `Parmana approvals >`, or from `APPROVAL_EMAIL_FROM` if you set it. A missing Resend key, a missing sender, or an address that is not valid stops the server at startup, so a broken setup cannot fail silently. A notification email looks like this: ```text Subject: Approval needed: paytm:refund ORD-1042 up to 75000 A request was refused because it needs a signed approval. Action: paytm:refund Target: paytm://orders/ORD-1042 Policy: customer-refund 1.2.0 Transaction: 5f0c2a7e-3d7b-4d0e-9a55-2f6d8b1c4e90 Refused at: 2026-09-29T10:15:02.114Z Sent by: refund-agent To approve, sign on your own machine: capability paytm:refund resourceId ORD-1042 maxAmount 75000 signal managerApproved Then send the approval to the agent, which resends the request with it. If you do not approve, do nothing: the request stays refused. ``` Each refusal sends at most one email: the send carries the idempotency key `approval-needed/`, so a retried send is not delivered twice. Delivery follows the same rules as the webhook: one attempt, a failure is logged and never changes the refusal. ## Webhook ### Set it up Any HTTPS endpoint that accepts a JSON `POST` and answers `2xx` within 3 seconds: a small function of your own, a Slack workflow webhook behind a relay, or an automation service. ```bash openssl rand -hex 32 ``` Store it where your endpoint can read it. You will verify every event with it. Set both variables on the deployment, then redeploy: ```bash APPROVAL_WEBHOOK_URL=https://hooks.example.com/parmana-approvals APPROVAL_WEBHOOK_SECRET= ``` With `PARMANA_SECRETS_PROVIDER=aws-secrets-manager`, `APPROVAL_WEBHOOK_SECRET` is the name or ARN of the secret instead of its value. Setting only one of the two stops the server at startup. The URL must be `https` outside `NODE_ENV=test` and `development`. Send a request the policy refuses only for want of an approval, for example a refund with `managerApproved: false` and every other fact true. Your endpoint receives an event. ## The event ```json { "type": "approval.needed", "occurredAt": "2026-09-29T10:15:02.114Z", "businessTransactionId": "5f0c2a7e-3d7b-4d0e-9a55-2f6d8b1c4e90", "decisionId": "a91e6c1d-0b8f-4f3a-8e57-6f2d1c9b7a44", "action": "paytm:refund", "target": "paytm://orders/ORD-1042", "policyId": "customer-refund", "policyVersion": "1.2.0", "reason": "No rule matched.", "submittedBy": "refund-agent", "approvals": [ { "signal": "managerApproved", "resourceId": "ORD-1042", "value": 75000 } ] } ``` Always `approval.needed`. When the request was refused, ISO 8601 UTC. The refused request. Its Refusal Record has the full decision. The refusal decision. The action that needs approval, such as `paytm:refund`. Sign with this as `capability`. The request's target. The policy that refused it. Its version. The refusal reason from the policy. The caller that sent the request, when known. One entry per approval the policy declares. The approval signal, such as `managerApproved`. The agent sets it to `true` on the new request. What the approval must name, read from the request at the policy's `resourceId` path. Sign with this as `resourceId`. Absent if the request did not carry it. The amount the approval must cover, read at the policy's `value` path. Sign with at least this as the maximum amount. Absent when the policy declares no amount. The event carries only what the approver needs. Other request parameters and signals are not sent; look them up by `businessTransactionId` if you need them. ## Verify the signature Every event has two headers: | Header | Value | | --------------------------- | ------------------------------------------------------------------------------------------ | | `parmana-webhook-timestamp` | Unix seconds when it was sent | | `parmana-webhook-signature` | `v1=` and the lowercase hex HMAC SHA256 of `.`, keyed with the secret | Verify against the raw request body, before parsing it, compare in constant time, and refuse a timestamp more than 5 minutes old so a captured event cannot be replayed later. ```typescript Node.js import { createHmac, timingSafeEqual } from "node:crypto"; import express from "express"; const app = express(); app.post( "/parmana-approvals", express.raw({ type: "application/json" }), (req, res) => { const timestamp = req.header("parmana-webhook-timestamp") ?? ""; const signature = req.header("parmana-webhook-signature") ?? ""; const body = req.body.toString("utf8"); const expected = "v1=" + createHmac("sha256", process.env.APPROVAL_WEBHOOK_SECRET!) .update(`${timestamp}.${body}`) .digest("hex"); const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300; const valid = expected.length === signature.length && timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); if (!fresh || !valid) { res.status(400).end(); return; } const event = JSON.parse(body); // Hand off to a queue or notify the approver, then answer fast. res.status(204).end(); }, ); ``` ```python Python import hashlib import hmac import json import os import time from flask import Flask, abort, request app = Flask(__name__) SECRET = os.environ["APPROVAL_WEBHOOK_SECRET"].encode() @app.post("/parmana-approvals") def approvals(): timestamp = request.headers.get("parmana-webhook-timestamp", "") signature = request.headers.get("parmana-webhook-signature", "") body = request.get_data() # raw bytes, before parsing expected = "v1=" + hmac.new( SECRET, timestamp.encode() + b"." + body, hashlib.sha256 ).hexdigest() fresh = timestamp.isdigit() and abs(time.time() - int(timestamp)) < 300 if not fresh or not hmac.compare_digest(expected, signature): abort(400) event = json.loads(body) # Hand off to a queue or notify the approver, then answer fast. return "", 204 ``` ## From event to approval The approver signs exactly what the event names: ```bash Script npx tsx scripts/sign-approval.ts \ --private-key-file ~/.parmana/manager-priya__manager-priya-key-1.private.pem \ --approver-id manager-priya --key-id manager-priya-key-1 \ --capability "$ACTION" --resource-id "$RESOURCE_ID" --max-amount "$VALUE" \ --out approval.json ``` ```typescript TypeScript import { signApproval } from "@parmana/sdk"; const [needed] = event.approvals; const approval = signApproval({ privateKeyPem, approverId: "manager-priya", keyId: "manager-priya-key-1", capability: event.action, resourceId: needed.resourceId, ...(needed.value !== undefined ? { maxAmount: needed.value } : {}), }); ``` ```python Python from parmana.crypto import sign_approval needed = event["approvals"][0] approval = sign_approval( private_key_pem=private_key_pem, approver_id="manager-priya", key_id="manager-priya-key-1", capability=event["action"], resource_id=needed["resourceId"], max_amount=needed.get("value"), ) ``` The agent then sends a new request, with a new `businessTransactionId`, the approval signal set to `true`, and the approval in `signals.approvalArtifact`. See [Human approval](/concepts/human-approval#approve-a-refund). An event says a request is waiting. It is not an instruction to approve it. The approver decides, on their own machine, with their own key; your endpoint should never sign approvals automatically. ## Delivery | Behavior | Detail | | ---------- | ----------------------------------------------------------------------------------------------------------------------- | | Attempts | One. There is no retry. | | Timeout | 3 seconds. Answer quickly and do slow work afterwards. | | Success | Any `2xx`. Anything else, a timeout, a redirect, or a network error is logged as `approval_needed_notification_failed`. | | Effect | None on the request. It is refused either way, and the agent's response is the same. | | Ordering | Events are sent in the order requests are refused, but may arrive out of order. | | Duplicates | Each refused request sends at most one event. An agent that retries sends one per request. | Because delivery is best effort, the Refusal Records remain the complete list of waiting requests. For a daily sweep, see [Review refused requests](/concepts/human-approval#review-refused-requests). ## Test locally Point the webhook at a local listener and run Parmana in development: ```bash NODE_ENV=development \ APPROVAL_WEBHOOK_URL=http://127.0.0.1:4000/parmana-approvals \ APPROVAL_WEBHOOK_SECRET=local-test-secret \ npm run dev ``` `http` is accepted only in `development` and `test`. ## Reference | Variable | Required | Description | | ------------------------- | --------------- | ------------------------------------------------------------ | | `APPROVAL_WEBHOOK_URL` | With the secret | Where events are sent. `https` outside development and test. | | `APPROVAL_WEBHOOK_SECRET` | With the URL | HMAC key, or a secret reference with a secrets provider. | See also [Environment variables](/deployment/environment-variables#approval-notifications). --- ## REST API Introduction Source: https://docs.parmanasystems.com/api-reference/introduction **[AVAILABLE]**. Every path in the sidebar under **REST API** is generated from `openapi/openapi.yaml`, which is structurally validated in CI (`npm run lint:openapi`, plus `openapi-bundle-refs.test.ts`'s $ref-integrity checks) on every push, and cross-checked against `packages/api/src/app.ts`'s real mounted routes by `openapi-route-parity.test.ts`: every real route needs a matching spec path, and every spec path needs a real, currently-mounted route behind it, in both directions, or the test fails by name. `GET /documentation` and `GET /reference` are the one explicit, allowlisted exception: rendered doc surfaces, not API resources, see [Views of the same spec](#views-of-the-same-spec) below. ## Base URL | Server | Base URL | Key | Use it for | | -------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | Public sandbox | `https://parmana-sandbox.vercel.app` | The published demo key, `2VfYWCzt_cBAPK-8uufX6ordfY2JuQhFPsohuEumKME`, allowed only `sandbox:receipt` | Trying every request for real with nothing installed. **Try it** panels call it by default. | | Production | `https://parmana-api-real.vercel.app` | A key issued by the operator | Real actions. Not open to the public. | | Local | `http://localhost:3000` | The committed demo key or your own, see [Quickstart](/quickstart) | Development on your own machine | The sandbox runs the same code as production, with its own database, keys and demo approver; its one action acts on nothing, and everything sent to it is visible to every visitor. See [Playground](/playground). The raw spec is also served at `GET /openapi.yaml` and, identically, JSON-serialized, at `GET /openapi.json` (some tooling and most AI coding agents expect a `.json` path by convention; both describe the same spec), see [Deploy patterns](/guides/deploy-patterns#the-openapi-spec-endpoint). **Are you an AI coding agent?** Fetch `GET /api-manifest.json` first. It's a single machine-readable index: the OpenAPI spec locations above, the auth scheme, and both real SDKs (`@parmana/sdk` on npm, `parmana` on PyPI) with their actual current package name and version, read directly from each package's own manifest, not hand-typed. It never claims a language has an SDK unless one is actually published, see [Other languages](/sdks/other-languages). ## Every endpoint has its own page The **REST API** tab lists every endpoint on its own page, grouped by area: Execution, Transactions, Verification, Receipts, Trust Records, Replay, Policies, Policy Governance, Approvers, External Connectors, Sandbox, Refusal Records, Execution Intents, Audit, System and Handbook. Each page is generated from the bundled OpenAPI file, so it shows the exact parameters, request body, every response with a real example, and a **Try it** panel that sends a real request to the public sandbox with the demo key filled in (switch the server to call your own). With the demo key, only `sandbox:receipt` and the read routes work; other capabilities return `403`, and governance routes return `403 NON_HUMAN_CALLER_DENIED`, so use their captured examples. A test fails if an endpoint is added to the spec without a page. Start with [Execute a Business Transaction](/api-reference/endpoints/execute-transaction), the endpoint every integration uses. If you are an AI agent, follow [Integrate Parmana: specification for AI agents](/agents/integrate) instead of reading the pages one by one. ## Views of the same spec Every route, request/response shape, and status code ultimately comes from one file, [`openapi/openapi.yaml`](https://github.com/pavancharak/parmana/blob/main/openapi/openapi.yaml), version-controlled alongside the code that implements it. That one spec drives three surfaces, so none of them can drift from what the server actually does: Guides, SDKs, and the per-route reference in the sidebar under **REST API**, each with real captured examples and a working curl snippet. Live Swagger UI on the real deployment, with **Try it out** wired to a real bearer key, for hands-on testing, not just reading. Live, read-only, single-page ReDoc view of the full spec, no **Try it out** button, built for auditors and integration partners who just need to read it. ### Postman [`postman/parmana.postman_collection.json`](https://github.com/pavancharak/parmana/blob/main/postman/parmana.postman_collection.json) has one request for every endpoint, in one folder per area, with the example bodies from the spec. It is generated from the same OpenAPI file (`npm run generate:postman`), and a test fails if it falls behind. 1. In Postman, choose **Import** and select the file. 2. Create an environment with a secret variable `apiKey` holding your API key, or the sandbox demo key `2VfYWCzt_cBAPK-8uufX6ordfY2JuQhFPsohuEumKME` to try it. The collection sends it as `Authorization: Bearer {{apiKey}}`; requests the API marks as public send no key. Never put a real key in the collection itself. 3. `baseUrl` defaults to the public sandbox, `https://parmana-sandbox.vercel.app`. Override it in the environment for production (`https://parmana-api-real.vercel.app`) or your own deployment, for example `http://localhost:3000`. Most example bodies use sample ids, so replace them before sending. Approving or rejecting a policy change needs a fresh step up signature from the approver's own machine; the sample signature in those two requests has expired. See [Policy lifecycle and approvals](/guides/policy-lifecycle-and-approvals). Every route's own base URL, including on your own deployment, not just the one linked above, is documented on [Deploy patterns](/guides/deploy-patterns#the-openapi-spec-endpoint). This page and its siblings cover what a spec can't express: The bearer key model, and how it differs from Policy evaluation and gateway attestation. The shared error envelope, which errors carry a code, and the one known gap. Why there's no Idempotency-Key header, what businessTransactionId does instead, and how authorizations and approvals are accepted once. Every error this API returns, one table, cross-linked from the spec. ## Errors Non 2xx responses are `{ "error": string }`, often with a stable `code`. Branch on the status, then on `code`, never on the text. Which errors carry a code, and the one known gap, are on [Error handling](/api-reference/error-handling); every specific error this API returns is on the [Error catalog](/api-reference/error-catalog). Both maintained SDKs raise a specific exception per status code. The Python SDK: `ValidationError`/`NotFoundError`/`ConflictError`/`ServerError`/etc., see [Python SDK](/sdks/python). The TypeScript SDK: `ValidationError`/`AuthenticationError`/ `AuthorizationError`/`NotFoundError`/`ConflictError`/`ExecutionRejectedError`/etc., see [TypeScript SDK](/sdks/typescript). ## Auth **Every route requires a caller bearer key, except the liveness/readiness probes and documentation/verification routes** listed on [Authentication](/api-reference/authentication). See that page for how to send a key and what it does and does not prove. ## Rate limiting `POST /execute` is rate-limited per authenticated caller; `GET /health` and `GET /ready` carry a separate, more permissive limit keyed by IP. See [Authentication](/api-reference/authentication#rate-limiting) for the mechanism and [Error catalog](/api-reference/error-catalog) for the exact `429` shape. ## Versioning `GET /version` (with a key) reports `{"name":"Parmana","version":"","api":"v1"}`; `GET /api-manifest.json` reports the same two values without one, as `apiVersion` and `buildVersion`. The `api` field is the REST API's version, currently `v1`; the `version` field is a separate deployment build identifier, not derived from it. `openapi/openapi.yaml`'s own `info.version` tracks the spec document, currently `1.0.0`. There's no fixed release cadence: routes and fields have been added within `v1` as this API has grown (see [Changelog](/changelog)), and existing fields and status codes haven't been removed or had their meaning changed. A breaking change to the REST API surface itself would be a new version (`v2`), not a silent change to `v1`. Both SDKs follow semantic versioning (`MAJOR.MINOR.PATCH`): a major version indicates a breaking change to the SDK's public surface (`ParmanaClient`, its domain models, its error types), a minor version adds functionality without breaking existing code, and a patch is a backward-compatible fix. Check the currently published version with `npm view @parmana/sdk version` or `pip index versions parmana`, see [TypeScript SDK](/sdks/typescript) and [Python SDK](/sdks/python). --- ## Authentication Source: https://docs.parmanasystems.com/api-reference/authentication Every request carries one API key as a bearer token. The key says **who is calling**. It does not authorize an action: a policy and a signed human approval do that, after the key is accepted ([How Parmana thinks](/how-parmana-thinks)). ## Send a bearer key ```bash curl https://parmana-sandbox.vercel.app/callers/me \ -H "Authorization: Bearer $PARMANA_API_KEY" ``` Every route needs the header except these: | Routes | Why no key | | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /health`, `GET /ready` | Probes: an orchestrator has no key to present. | | `GET /openapi.yaml`, `GET /openapi.json`, `GET /api-manifest.json`, `GET /documentation`, `GET /reference` | Discovery: a client reads how to use the API before it has a key. | | `POST /refusal/verify`, `POST /execution-intents/verify`, `POST /audit/verify`, `GET /keys/{keyId}`, `GET /.well-known/jwks.json` | Independent verification: anyone holding a signed artifact can check it with no Parmana account. See [Verify independently](/guides/verify-independently). | | `GET /parmana-handbook.pdf`, `GET /handbook/download-leads`, `POST /handbook/download-leads` | The handbook download, for visitors. | A missing header, a header that is not `Bearer `, or a key the server does not know returns `401` before anything else runs: ```json 401 { "error": "authentication required" } ``` The server holds only a SHA-256 hash of each key and compares in constant time. Responses, records and audit events name the key by its `callerId`, never by the key itself. ## Check your key: GET /callers/me Call this first. It returns who the key is and what it may do, with the defaults below already applied. Real response from the sandbox with the demo key: ```json 200 { "callerId": "sandbox-visitor", "allowedPrincipalIds": ["sandbox-visitor"], "allowedCapabilities": ["sandbox:receipt"], "unrestrictedCapabilities": false } ``` `unrestrictedCapabilities` is `true` only for a key granted every capability (`"*"`). ## What a key may do Each key is one entry in the server's `PARMANA_API_KEYS` (a JSON array). The entry, not the request, decides what the key may do: | Field | Meaning | If left out | Refused with | | ---------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | `callerId` | Who the key is. Appears in every audit event. | Required | | | `keyHash` | SHA-256 of the raw key, 64 hex characters. | Required | | | `allowedCapabilities` | The actions (`intent.action`) the key may ask for. `"*"` allows every action. | **Nothing.** A key with no list may invoke none. | `403 CAPABILITY_NOT_ALLOWED` | | `allowedPrincipalIds` | The `authority.principalId` values the key may declare. | Only its own `callerId`. | `403` with no code, `Caller is not permitted to assert this authority.principalId.` | | `allowedTenantIds` | The `metadata.tenantId` values the key may send. The tenant id picks the signing key. | **None.** A request with a `tenantId` is refused; one without is unaffected. | `403 TENANT_NOT_ALLOWED` | | `credentialHolderType` | `USER` marks a key held by a verified person. Only `USER` keys may use governance routes. | Not a person. | `403 NON_HUMAN_CALLER_DENIED` | | `stepUpPublicKey` | The public half of a checker's step up key, for approving or rejecting governance changes. | The key cannot approve or reject. | See [Policy governance](/guides/policy-governance-maker-checker) | An agent's key is usually `allowedCapabilities` with its few actions and nothing else. A person who approves policy or approver changes holds a separate `USER` key with a step up key ([Credentials](/guides/credentials-maker-checker-approver)). ## Where keys come from The operator of the server issues keys. There is no API to create one. On a machine with the repository: ```bash npx tsx scripts/generate-api-key.ts --caller-id refund-agent \ --allowed-capabilities paytm:refund ``` Other options: `--allowed-principal-ids a,b`, `--credential-holder-type USER`, and `--generate-step-up-key` (with `USER`). The script prints the raw key once, with its hash and the entry to add, and writes nothing to disk: ```text Add this entry to PARMANA_API_KEYS (a JSON array): {"callerId":"refund-agent","keyHash":"<64 hex characters>","allowedCapabilities":["paytm:refund"]} ``` Give the raw key to the caller over a secure channel, add the entry to `PARMANA_API_KEYS`, and restart or redeploy the server. To revoke a key, remove its entry and redeploy. To rotate, add the new entry, move the caller, then remove the old one. The full procedure is in [API keys](/self-hosted/api-keys) and [Deploy patterns](/guides/deploy-patterns#caller-authentication). The server refuses to start with no keys configured, unless `PARMANA_AUTH_DISABLED=true`. That switch turns authentication off entirely, for local development and the tutorials only, and the server prints a warning at every start. **Never set it on a real deployment.** A sandbox refuses to start with it. ## The sandbox demo key The public sandbox, `https://parmana-sandbox.vercel.app`, has one published key: `2VfYWCzt_cBAPK-8uufX6ordfY2JuQhFPsohuEumKME`. It is caller `sandbox-visitor`, may act only as itself, and may invoke only `sandbox:receipt`, an action that acts on nothing. It is not a human key, so governance routes refuse it with `403 NON_HUMAN_CALLER_DENIED`. Every visitor shares it, so every visitor can read what others sent with it. It works only on the sandbox; production and every other deployment refuse it with `401`. See [Playground](/playground). ## Rate limits | Routes | Counted per | Default per minute | Setting | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------------------ | ------------------------------------ | | `POST /execute` | Key (`callerId`) | 30 | `RATE_LIMIT_EXECUTE_PER_MINUTE` | | `GET /health`, `GET /ready` | Client address | 300 | `RATE_LIMIT_HEALTH_PER_MINUTE` | | `POST /refusal/verify`, `POST /execution-intents/verify`, `POST /audit/verify`, `/handbook`, `POST /sandbox/approvals`, `GET /keys/{keyId}`, `GET /.well-known/jwks.json` | Client address | 60 | `RATE_LIMIT_PUBLIC_PER_MINUTE` | | Failed authentication (`401`) on any route that needs a key | Client address | 30 | `RATE_LIMIT_AUTH_FAILURE_PER_MINUTE` | `POST /execute` is counted per key, not per address, because many agents call from one shared backend address. Over the limit, the request is refused before anything is evaluated, signed or recorded: ```json 429 { "error": "Rate limit exceeded. Try again later.", "code": "RATE_LIMITED" } ``` with a `Retry-After` header in seconds, and `RateLimit` headers on every limited response. Wait that long, then send again with a **new** `businessTransactionId`. With `DATABASE_URL` set, as on every hosted deployment, the counts are shared by all server instances, so the limit is the limit. Without it, each instance counts on its own. ## Errors Real bodies, from the sandbox. | Status | When | Body | | ------ | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `401` | No header, not `Bearer `, or an unknown key | `{"error":"authentication required"}` | | `403` | `intent.action` is not in the key's `allowedCapabilities` | `{"error":"Caller is not permitted to invoke this capability.","code":"CAPABILITY_NOT_ALLOWED"}` | | `403` | `authority.principalId` is not one the key may declare | `{"error":"Caller is not permitted to assert this authority.principalId."}` | | `403` | `metadata.tenantId` is not in the key's `allowedTenantIds` | `{"error":"Caller is not permitted to act for this tenantId.","code":"TENANT_NOT_ALLOWED"}` | | `403` | A governance route with a key that is not `USER` | `{"error":"This action requires a caller credential provisioned as a verified human (credentialHolderType: USER).","code":"NON_HUMAN_CALLER_DENIED"}` | | `429` | Over a rate limit | `{"error":"Rate limit exceeded. Try again later.","code":"RATE_LIMITED"}` | For each of these nothing is evaluated, approved or released. The `401` and `403` refusals are written to the caller audit trail; a `429` is refused before that. Fix the key or the request and send again. Every other error is in the [Error catalog](/api-reference/error-catalog). ## What a key does not prove A valid key proves which system is calling. It does not prove who authorized the action: that is the signed [Execution Authorization](/concepts/execution-authorization) and the signed human approval, checked after the key. A request with a valid key is still refused when the policy says no, and an approved request is released only through [the gateway](/concepts/the-gateway), which checks its own signatures. --- ## Error Handling Source: https://docs.parmanasystems.com/api-reference/error-handling **[AVAILABLE]**. `packages/api/src/middleware/error-handler.ts`. Every response shown here was captured from the server on 2026-10-02. ## The envelope Every non 2xx response (except `POST /policies/validate`, see below) is: ```json { "error": "string, always present, human readable" } ``` and many also carry a stable, machine readable `code`: ```json { "error": "No connector is registered for capability 'crm:update-deal' on this deployment. Nothing was executed. The operator must configure the connector.", "code": "CONNECTOR_NOT_REGISTERED" } ``` `error` is always a plain string, never a nested object, so a caller can always display or log it without walking a schema. Its wording can change; `code` and the status do not. **Branch on the status first, then on `code` when it is present. Never match the text of `error`.** ## Which errors carry a code `code` is present when the failure is one of the server's typed errors. Among them, the ones an integration meets most: | Status | `code` | Meaning | | ------ | --------------------------- | -------------------------------------------------------------------------------------------------------- | | 403 | `POLICY_DENIED` | The policy refused the action, including a missing, expired or already used human approval. Nothing ran. | | 403 | `CAPABILITY_NOT_ALLOWED` | Your key may not invoke this capability. | | 403 | `TENANT_NOT_ALLOWED` | Your key may not send this `metadata.tenantId`. | | 403 | `NON_HUMAN_CALLER_DENIED` | A governance route refused a non human caller, such as an agent's key. | | 409 | `CONFLICT` | A governance change contradicts the current state, for example one is already open. | | 429 | `RATE_LIMITED` | Too many requests for this key. Wait and retry. | | 502 | `EXECUTION_OUTCOME_UNKNOWN` | The action was released and the call to the connector failed. **Do not retry as a new transaction.** | | 503 | `CONNECTOR_NOT_REGISTERED` | No connector serves this capability on this deployment. Nothing was executed. | | 503 | `SIGNING_UNAVAILABLE` | The server could not sign. Nothing was released. | The [Error catalog](/api-reference/error-catalog) lists every code, its trigger and what to do. ## Which errors have no code These answer `{ "error": "..." }` only, so tell them apart by status: | Status | When | Example `error` | | ------ | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | | 400 | The body is not valid JSON | `Malformed JSON body.` | | 400 | `businessTransactionId` is missing or not a UUID | `businessTransactionId must be a valid UUID.` | | 400 | A field breaks the transaction's own rules, or a policy or signal fails validation | `metadata.businessTransactionId must match businessTransactionId.` | | 401 | No key, or a key the server does not know | `authentication required` | | 403 | Your key may not assert this `authority.principalId` | `Caller is not permitted to assert this authority.principalId.` | | 404 | The policy named in the request does not exist | `Policy 'does-not-exist' version '9.9.9' was not found.` | | 409 | The `businessTransactionId` was already used, see [Idempotency](/api-reference/idempotency-and-nonces) | `Business Transaction '' already exists.` | | 413 | The body is too large | `Payload too large.` | | 500 | Anything unexpected | `Internal Server Error` | **`POST /policies/validate` does not use this envelope.** Every status it returns (200, 400, 404) is `{"valid": boolean, "errors": string[]}`, for example `{"valid": false, "errors": ["policyId is required."]}`. The one exception is `401`: caller authentication runs before the route, so a rejected caller sees `{"error": "authentication required"}`. ## An incomplete body is a 400 A `POST /execute` or `POST /transactions` body without `metadata`, `authority`, `authorization`, `intent` or `policy`, or with one of them `null` or not an object, or with a required field that is not a string, is refused with `400` naming it, for example: ```json { "error": "policy is required and must be an object." } ``` Nothing is executed. Until 2026-10-06 a missing `policy` or `authorization` reached the validator and was answered with a bare `500` (G-87 in `docs/VERIFICATION-GAPS.md`). A fuzz test now sends damaged and arbitrary bodies to both routes and requires that neither ever answers `500`. Fixed earlier too: "no connector registered for this capability" used to be a bare `500` too. It is now `503 CONNECTOR_NOT_REGISTERED`. ## The SDKs Both SDKs raise one exception class per status. In the TypeScript SDK, `AuthorizationError` and `InternalServerError` keep the server's `code` on `serverCode`. See [TypeScript SDK](/sdks/typescript) and [Python SDK](/sdks/python) for each class. --- ## Idempotency and Nonces Source: https://docs.parmanasystems.com/api-reference/idempotency-and-nonces **[AVAILABLE]**. No `Idempotency-Key` header exists anywhere in this API. This page describes the real mechanism, not a gap. ## businessTransactionId is the idempotency key, but not Stripe's kind `POST /execute` and `POST /transactions` require a caller supplied `businessTransactionId` (a UUID). Sending the same one twice does **not** replay the first response the way Stripe's `Idempotency-Key` does. The second request fails with `409` and no `code`: ```json { "error": "Business Transaction 'a1a1a1a1-1111-4111-8111-111111111111' already exists." } ``` The 409 is the same whether the first request was approved, refused or is still running, so it tells you only that the id was used. To find out what happened to it, read it back with the same id: | The first request was | Read | Answer | | ---------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | Approved and executed | `GET /trust-records/{businessTransactionId}` | `200` with the Execution Trust Record | | Refused (`403 POLICY_DENIED` and others) | `GET /refusal/{businessTransactionId}` | `200` with the signed Refusal Record. `GET /trust-records/{id}` answers `404`: a refusal has no Trust Record. | | Either | `GET /transactions/{businessTransactionId}` | `200` with the Business Transaction as it was stored | **Practical guidance for callers:** derive `businessTransactionId` from your own idempotency key (a UUIDv5 of your order id, for example) so a retry uses the same id. Treat a `409` on that id as "already submitted, go and read it", not as a failure. **After a timeout, read before you send again.** An approved request can take tens of seconds (see `docs/VERIFICATION-GAPS.md` G-84), longer than a client's default timeout. The request keeps running on the server, and the action may be performed. If your client times out, read `GET /trust-records/ {businessTransactionId}` (and `GET /refusal/{id}`) with the same id first. Sending the action again under a **new** id would ask for it a second time. Set your client timeout to 120 seconds. ## Nonces: each authorization and each approval is accepted once A caller never sends or manages a nonce. The word means two single use values inside the server's own pipeline: - **The authorization nonce.** Every approved decision becomes a `SignedExecutionAuthorization` with its own `nonce`. The [gateway](/concepts/the-gateway) consumes it before it releases the action, so one authorization can release one action. Over HTTP you never meet this check: a repeated `businessTransactionId` is refused with `409` before a second authorization exists. It protects a system that calls `@parmana/execution-gateway` directly, which refuses a replay with `NONCE_ALREADY_CONSUMED`. - **The approval nonce.** A signed human approval carries a nonce too, and is accepted once. Sending the same approval again, even under a new `businessTransactionId`, is refused with `403 POLICY_DENIED`: the approval no longer verifies, so the policy's approval signal is false (`declared signal(s) do not match independently verified state (humanApproved=true != verified humanApproved=false)`). Ask the approver for a new approval. See [Human approval](/concepts/human-approval). Both are stored in Postgres (`consumed_nonces` and `consumed_approval_nonces`), so they hold across every instance of a deployment, not per process. Only the test suite (`NODE_ENV=test`) keeps them in memory. See [Execution Authorization](/concepts/execution-authorization) for the full mechanism. ## What this means together Three replay defenses exist at different layers, and none is an `Idempotency-Key` header: | Layer | Mechanism | What repeating gets you | | ---------------------------------- | ----------------------------------- | --------------------------------------------------------------- | | REST API (`businessTransactionId`) | Unique key in the database | `409`, not the original response | | Human approval (approval nonce) | Single use, checked in the database | `403 POLICY_DENIED`, nothing runs | | Internal authorization (`nonce`) | Single use, consumed by the gateway | Refused by the gateway (`NONCE_ALREADY_CONSUMED`), nothing runs | None of them is configurable per request, and there is no way to opt out of the `businessTransactionId` uniqueness check from the API. --- ## Error Catalog Source: https://docs.parmanasystems.com/api-reference/error-catalog **[AVAILABLE]**. Rows come from responses captured from the server and from the integration tests that assert them, checked against the code on 2026-10-02. See [Error handling](/api-reference/error-handling) for the envelope shape this table assumes. ## Authentication | Status | Code | Message | Condition | What to do | | ------ | ------------------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | 401 | none | `authentication required` | `Authorization` header missing, not a `Bearer `, or the key doesn't match a configured hash | Send a valid key from [Authentication](/api-reference/authentication). Not retryable without fixing the header. | | 503 | `AUDIT_UNAVAILABLE` | `Caller authentication audit trail is unavailable; refusing to proceed without an audit record.` | The server could not write the caller audit event a request needs (a database outage). Any authenticated route. Nothing was done. | Retry later with the same request. | ## Authorization (caller is known; not permitted for this principal or action) | Status | Code | Message | Condition | Routes | What to do | | ------ | ------------------------ | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 403 | none | `Caller is not permitted to assert this authority.principalId.` | `transaction.authority.principalId` is missing/empty, or doesn't equal the authenticated `callerId` (default, no `allowedPrincipalIds` configured for this key), or isn't present in that key's configured `allowedPrincipalIds` list (`isPrincipalAllowed`, `packages/api/src/routes/execute.ts` / `transactions.ts`) | `POST /execute`, `POST /transactions` | Set `authority.principalId` to an identity this caller's key is actually permitted to assert. Skipped entirely (no 403 possible) when caller authentication itself is disabled. | | 403 | `CAPABILITY_NOT_ALLOWED` | `Caller is not permitted to invoke this capability.` | `intent.action` is not in the key's `allowedCapabilities`, or the key has no `allowedCapabilities` (a key with none may invoke nothing) | `POST /execute`, `POST /transactions` | Use a key granted that action; `GET /callers/me` lists what yours may invoke. Nothing was evaluated. | | 403 | `TENANT_NOT_ALLOWED` | `Caller is not permitted to act for this tenantId.` | `metadata.tenantId` is set and is not in the key's `allowedTenantIds` (a key with none may send no tenant id). The tenant id selects the signing key, so it must come from the key | `POST /execute` | Remove `metadata.tenantId`, or ask the operator to add the tenant to the key's `allowedTenantIds`. Nothing was evaluated. | ## Policy denial (request well-formed; the business rule said no) | Status | Code | Message | Condition | Routes | What to do | | ------ | --------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 403 | `POLICY_DENIED` | `Execution rejected: ` | Policy evaluation returned `REJECTED` (`ExecutionGate.enforce`, `packages/runtime/src/ExecutionGate.ts`). This includes a required human approval that is missing, expired, for other content or already used (the reason then names the approval signal, for example `humanApproved=true != verified humanApproved=false`), a bound signal that does not match the Intent, and a request naming a policy version that is not the one in effect | `POST /execute`, `POST /transactions` | This is a real, observed pipeline outcome, not a bug: the request was well-formed but the business decision was no. Read the reason string; it names the specific Policy condition that failed. Distinct from the caller identity 403 above: that one is about who the caller may act as, this one is about whether the requested action itself is allowed. Previously indistinguishable from a genuine server error (both surfaced as a generic `500`), now corrected. | ## Validation (caller's request is malformed) | Status | Code | Message | Condition | Routes | What to do | | ------ | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | 400 | none | `businessTransactionId must be a valid UUID.` | `businessTransactionId` present but not a valid UUID v1-v5 | `POST /execute`, `POST /transactions`, `POST /verify`, `POST /receipt` | Fix the ID format. Not a transient failure. | | 400 | none | `businessTransactionId is required.` | `businessTransactionId` missing entirely | `POST /replay` | Include the field. `/replay` checks presence only, not UUID format, unlike the routes above. | | 400 | none | `authorization.authorityId must match authority.authorityId.` | A Business Transaction trust-chain invariant failed (`BusinessTransactionValidationError`) | `POST /execute`, `POST /transactions` | Fix the request body's internal consistency; this is one example invariant among several the shared validator enforces. | | 400 | none | `{"valid":false,"errors":["policyId is required."]}` / `["policyVersion is required."]` | `policyId` or `policyVersion` missing/empty, checked in that order, first miss short-circuits | `POST /policies/validate` | Supply both fields. This route never uses the shared `{error}` envelope, see [Error handling](/api-reference/error-handling). | | 400 | `INVALID_SANDBOX_APPROVAL_REQUEST` | `The sandbox approver signs only for capability "sandbox:receipt".` or `resourceId is required: the request's target, at most 200 characters.` | Sandbox only: another capability, or a missing, empty or long `resourceId` | `POST /sandbox/approvals` | Ask for `sandbox:receipt` with the target you will send. Nothing was signed. | ## Not found | Status | Code | Message | Condition | Routes | What to do | | ------ | ---------------------------------- | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | 404 | none | `Policy '' version '' was not found.` | `transaction.policy.name`/`version` doesn't match any published Policy (`PolicyNotFoundError`) | `POST /execute`, `POST /transactions` | Publish the policy first, or fix the name/version in the request. | | 404 | none | `{"valid":false,"errors":["Policy '' version '' was not found."]}` | Same condition as above, different envelope | `POST /policies/validate` | Same fix; this route reshapes the error locally instead of using the shared handler. | | 404 | `APPROVAL_ISSUER_CHANGE_NOT_FOUND` | `Approver change '' not found.` | No approver change has this id | `POST /approval-issuers/changes/{id}/approve`, `.../reject` | Use the `changeId` the proposal returned. See [Manage approvers](/guides/manage-approvers). | | 404 | none | `Business Transaction not found.` | No Business Transaction exists for the path ID | `GET /transactions/{id}` | Confirm the ID; nothing to retry. | | 404 | `VERIFICATION_FAILED` | `Execution Trust Record not found.` | No Execution Trust Record exists for `businessTransactionId` (`VerificationFailedError`, reused across three POST routes) | `POST /verify`, `POST /receipt`, `POST /replay` | Confirm the record exists via `GET /trust-records/{id}` first. | | 404 | none | `Execution Trust Record not found.` | Same underlying condition, but reached via a GET, no `code` field on this path | `GET /verification/{id}`, `GET /receipt/latest/{id}`, `GET /trust-records/{id}` | Same fix; note the `code` field's presence depends on which route you called, not on the underlying cause. | | 404 | none | `Verification not found.` | Record exists but has no Verification yet | `GET /verification/{id}` | In practice every Execution produces a Verification synchronously, this is an edge case for records that haven't completed that step. | | 404 | none | `Receipt not found.` | Record exists but has no Receipt yet | `GET /receipt/latest/{id}` | Call `POST /receipt` first, or the record's Execution hasn't reached that stage. | | 404 | none | `No authenticated caller identity available.` | No caller identity on the request, in practice only reachable when caller authentication is disabled (`PARMANA_AUTH_DISABLED=true`, local development only) | `GET /callers/me` | Not reachable under normal, authenticated operation. See [Authentication](/api-reference/authentication). | ## Policy in effect (`GET /policies/in-effect`) | Status | Code | Message | Condition | What to do | | ------ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | 400 | none | `capability is required, for example ?capability=paytm:refund.` | The `capability` query parameter is missing or blank | Add `?capability=`. | | 403 | `CAPABILITY_NOT_ALLOWED` | `Caller is not permitted to invoke this capability.` | A non human caller asked about a capability its key may not invoke | Ask only about capabilities your key may invoke (`GET /callers/me`). | | 404 | `CAPABILITY_NOT_BOUND` | `No policy is bound to capability "".` | No policy governs this capability on this deployment | Check the capability name. Every request for it would be refused. | | 409 | `NO_APPROVED_POLICY_VERSION` | `Every request for "" is refused right now: .` | The bound policy has no approved version yet | Every request is refused until a version is approved through [policy governance](/guides/policy-lifecycle-and-approvals). | | 503 | `POLICY_VERSION_UNAVAILABLE` | `Every request for "" is refused right now: .` or `The policy in effect for "" could not be read.` | The lookup of the version in effect, or the policy file itself, failed | Retry later. Every request for the capability is refused meanwhile. | ## Governance (policy changes, approvers, external connectors) These come from the maker checker routes: `/policies/{name}/{version}/pending-changes` and `/policies/pending-changes`, `/approval-issuers/changes`, and `/external-connectors/changes`. Every one of them, reads included, needs a human caller; approve and reject also need a fresh step up signature from the checker. See [Policy lifecycle and approvals](/guides/policy-lifecycle-and-approvals), [Manage approvers](/guides/manage-approvers) and [Connect any external system](/guides/connect-any-external-system). | Status | Code | Message | Condition | Routes | | ------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | | 400 | none | The field and its rule, for example `reason is required, at most characters.` | A field of the proposal is missing or invalid. Nothing was stored. | Every propose route | | 400 | `INVALID_EXTERNAL_CONNECTOR` | The field and its rule, for example `policy must be a policy name matching ^[a-z0-9][a-z0-9-]{0,127}$.` | A registration or revoke proposal is invalid | `POST /external-connectors/changes` | | 400 | `EXTERNAL_ENDPOINT_ADDRESS_REFUSED` | `The endpoint host '' does not resolve.` or `... resolves to an address that is not public ().` | The endpoint is not a public HTTPS address. Checked when proposed and again when approved. | `POST /external-connectors/changes`, `.../approve` | | 403 | `NON_HUMAN_CALLER_DENIED` | `This action requires a caller credential provisioned as a verified human (credentialHolderType: USER).` | The key is not provisioned as a human (`credentialHolderType: USER`), such as an agent's key or the sandbox demo key. Recorded as a flagged audit event. | Every governance route | | 403 | `SAME_ACTOR_CANNOT_APPROVE_OWN_CHANGE` | ` '' was proposed by this same caller — the proposer (maker) may not also approve or reject it (checker).` | The checker is the maker | Every approve and reject route | | 403 | `STEP_UP_AUTHORIZATION_INVALID` | `The step-up authorization envelope is missing, invalid, expired, replayed, or does not match this request.` | The step up signature is missing, not from the checker's registered key, expired, already used, or for another change or action | Every approve and reject route | | 404 | `PENDING_POLICY_CHANGE_NOT_FOUND` | `Pending Policy Change '' not found.` | No policy change has this id | `POST /policies/pending-changes/{id}/approve`, `.../reject` | | 404 | `EXTERNAL_CONNECTOR_CHANGE_NOT_FOUND` | `External connector change '' not found.` | No external connector change has this id | `POST /external-connectors/changes/{id}/approve`, `.../reject` | | 409 | `CONFLICT` | For example `A PENDING_APPROVAL change already exists for policy '' version '' ...`, `Approver change '' is already APPROVED.`, `Capability '' already has an active external connector. Revoke it first.` | The change contradicts the current state: another change is already open for the same thing, the change was already resolved, or the key or registration already exists or does not | Every propose, approve and reject route | `APPROVAL_ISSUER_CHANGE_NOT_FOUND` (404) is in [Not found](#not-found) above. ## Rate limited | Status | Code | Message | Condition | Routes | What to do | | ------ | -------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 429 | `RATE_LIMITED` | `Rate limit exceeded. Try again later.` | Caller (by authenticated identity, not IP) exceeded the configured `/execute` rate limit, or the caller's IP exceeded the separate, more permissive `/health`/`/ready` limit, or the shared per IP limit on the unauthenticated verify routes and `/handbook` | `POST /execute`, `GET /health`, `GET /ready`, `POST /refusal/verify`, `POST /execution-intents/verify`, `POST /audit/verify`, `/handbook/download-leads`, `POST /sandbox/approvals` | Back off; the response carries a `Retry-After` header. Not a transient server issue: no nonce is consumed and nothing is signed for a request rejected here. See [Authentication](/api-reference/authentication#rate-limiting) for the mechanism. | ## Conflict | Status | Code | Message | Condition | Routes | What to do | | ------ | --------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | 409 | none | `Business Transaction '' already exists.` | A Business Transaction with this `businessTransactionId` already exists (`DuplicateBusinessTransactionError`) | `POST /execute`, `POST /transactions` | This is not a transient failure to retry. Fetch the existing record instead, see [Idempotency and nonces](/api-reference/idempotency-and-nonces). | | 409 | `RECEIPT_GENERATION_FAILED` | `Execution Trust Record must be successfully verified before a Receipt can be generated.` | The record exists but its latest Verification isn't `VERIFIED` | `POST /receipt` | Call `POST /verify` first and confirm `status: VERIFIED` before requesting a Receipt. | **Not listed here on purpose:** `NonceAlreadyConsumedError` (409, code `NONCE_ALREADY_CONSUMED`) is a real, tested error `error-handler.ts` maps, thrown by `ExecutionGateway.execute()` when every other verification check passed and only nonce consumption failed (an isolated replay of an already-executed authorization). It is not reachable through any of the routes this catalog covers: a resubmitted `businessTransactionId` is always caught first by the `DuplicateBusinessTransactionError` 409 above, before a second authorization, and so a second nonce, is ever generated. It's a library-level guarantee for a receiving system calling `@parmana/execution-gateway` directly, not an HTTP-reachable outcome of Parmana's own server. ## Signing and evidence (the action could not be recorded) | Status | Code | Message | Condition | | ------ | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 503 | `SIGNING_UNAVAILABLE` | `Evidence signing is unavailable; refusing to release the action ... Nothing was executed.` | Before release, the signing readiness probe failed (a missing, disabled or denied key, a KMS or network outage, a key mismatch). Nothing was executed. Retry once signing is healthy, with a new `businessTransactionId`, because the original was already accepted. | | 503 | `EXECUTION_INTENT_UNAVAILABLE` | `The signed Execution Intent for business transaction '' could not be created and stored; refusing to release the action. Nothing was executed. ...` | Before release, the signed [Execution Intent](/concepts/execution-intents) could not be signed and stored (a database or signing failure, or the `execution_intents` table is missing). **Nothing was released.** Retry once healthy, with a new `businessTransactionId`, because the original was already accepted. | | 503 | `CONNECTOR_NOT_REGISTERED` | `No connector is registered for capability '' on this deployment. Nothing was executed. ...` | The action was approved, but no connector is registered for it, usually because the connector's credentials are not configured. The operator must configure it, then retry with a new `businessTransactionId`. | | 500 | `EXECUTION_RECORD_INCOMPLETE` | `The action for business transaction '' was released ... reconcile ... authorizationId: .` | The action was released to the connector, then the signed Execution Trust Record could not be produced or persisted. **Do not retry as a new transaction.** A signed [Execution Intent](/concepts/execution-intents) exists for it. Read `GET /execution-intents/`: in state `RELEASED`, an operator can rebuild the record with `POST /execution-intents//finalize`, which never calls the connector. In state `PREPARED`, finalize refuses with `409`, and the outcome must be reconciled against the connector by hand. | | 409 | `AUTHORIZATION_NO_LONGER_VALID` | `Not released: what the authorization for business transaction '' rests on no longer holds (...). Nothing was executed. ...` | RFC-0023 phase 4. The request was decided, but at release a business fact it rested on was past its `validUntil`, or the agent's authority grant had expired or been revoked. Nothing was sent to the connector, and the body says `executionStatus: NOT_EXECUTED`. Send a new request, which is decided again on current facts and authority. The [Execution Intent](/concepts/execution-intents) is `ERRORED`; close it as `NOT_EXECUTED`. | | 502 | `EXECUTION_OUTCOME_UNKNOWN` | `The action for business transaction '' was released to the execution system, but the call failed, so whether it was performed is unknown. ... authorizationId: .` | The action was released and the call to the connector failed: unreachable, timed out, or an error response. Whether it was performed is unknown, and the [Execution Intent](/concepts/execution-intents) is `ERRORED`. **Do not retry as a new transaction.** Check the target system, then close the intent with `POST /execution-intents//resolve`, recording `EXECUTED` or `NOT_EXECUTED`. The connector's own error is never in the response; it is in the server log (`execution_outcome_unknown`) and in the intent's `failureReason`. Before 2026-09-25 this was a bare `500 Internal Server Error` (G-63). Since RFC-0023 phase 4 the body also says `executionStatus: EXECUTION_UNKNOWN`: never reported as failed. | ## Execution Intents (operator routes) These come from the routes under `/execution-intents`, see [Execution Intents](/concepts/execution-intents). | Status | Code | Message | Condition | Routes | | ------ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | 400 | none | `Request body must be an Execution Intent (intentId, businessTransactionId, authorizationId, intentHash, signature required).` | The body of the verify route is not a structurally plausible intent. | `POST /execution-intents/verify` | | 403 | `NON_HUMAN_CALLER_DENIED` | `This action requires a caller credential provisioned as a verified human (credentialHolderType: USER).` | The list and finalize routes need a credential provisioned as a verified human. Any other credential is refused. | `GET /execution-intents/unfinalized`, `POST /execution-intents//finalize` | | 404 | none | `Execution Intent not found.` | No intent exists for the id, or it belongs to a different caller (ownership scoped). | `GET /execution-intents/` | | 404 | `EXECUTION_INTENT_NOT_FOUND` | `No Execution Intent exists for business transaction ''. ...` | Finalize was called for a transaction with no intent. Transactions created before Execution Intents have none. | `POST /execution-intents//finalize` | | 409 | `EXECUTION_INTENT_RESULT_NOT_RECORDED` | `The Execution Intent for business transaction '' is in state and has no saved execution result ...` | The intent exists but no execution result was saved, so the Trust Record cannot be rebuilt. The action may or may not have been released. Establish the outcome at the connector and reconcile by hand. Nothing was called or changed. | `POST /execution-intents//finalize` | | 501 | `EXECUTION_INTENTS_NOT_ENABLED` | `Execution Intents are not enabled on this deployment.` | Finalize was called on a deployment where Execution Intents are off. Only possible in `test` or `development` without `EXECUTION_INTENTS_CHECK=true`. | `POST /execution-intents//finalize` | | 400 | `EXECUTION_INTENT_RESOLUTION_INVALID` | `The resolution is invalid: ` | The body of the resolve route is invalid: `resolution` is not `NOT_EXECUTED` or `EXECUTED`, or `note` is missing, blank or over 2000 characters. Nothing was changed. | `POST /execution-intents//resolve` | | 409 | `EXECUTION_INTENT_NOT_RESOLVABLE` | `The Execution Intent for business transaction '' is in state and cannot be resolved by hand. ...` | The intent is `RELEASED` (rebuild the record with finalize instead), `FINALIZED` (already complete), or a signed Trust Record already exists for the transaction. Only `PREPARED` and `ERRORED` intents can be closed by hand. Nothing was changed. | `POST /execution-intents//resolve` | ## Server-side (500) | Status | Code | Message | Condition | Routes | What to do | | ------ | ---- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | 500 | none | `Internal Server Error` | A structurally incomplete but UUID-valid body (for example, `businessTransactionId` present but `metadata` missing) throws an unhandled `TypeError` inside `BusinessTransactionValidator.validate` before any typed error class is constructed | `POST /execute`, `POST /transactions` | Send every required nested object from the [OpenAPI reference](/api-reference/introduction)'s request schema. The generic message hides the real cause; it is visible only in server logs. | ## What's deliberately not here This catalog lists errors from the routes mounted in `packages/api/src/app.ts`. It does not cover exceptions raised only in code paths unreachable from the HTTP API (for example, internal validator classes that never escape a route handler uncaught), or client-side SDK exceptions, see [Python SDK](/sdks/python) and [TypeScript SDK](/sdks/typescript) for how each wraps these same HTTP responses. --- ## 9. Errors and troubleshooting Source: https://docs.parmanasystems.com/build-book/09-errors-and-troubleshooting Every error body is `{ "error": "", "code": "" }`. A few answers carry no `code`; the message then says what is wrong. Every code, with the operations that return it, is in the [error reference](/build-book/reference-errors). ## The one rule **Know whether the action may have run before you retry.** Parmana's answers fall into three groups: | Group | Answers | Retry? | | ------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | Refused, nothing ran | `400`, `401`, `403`, `404`, `409` (except `NONCE_ALREADY_CONSUMED`), `429`, `503` | Fix the cause, then send a **new** transaction. `429`: wait for `Retry-After`. | | Released, the record is missing | `500 EXECUTION_RECORD_INCOMPLETE` | **No.** The action ran. The operator finalizes the record ([Chapter 7](/build-book/07-verify-and-audit)). | | Released, the outcome unknown | `502 EXECUTION_OUTCOME_UNKNOWN` | **No.** The action may have run. The operator checks the system and resolves the intent. | The SDKs never retry a `POST` on their own; they retry only `GET` requests, and only when you configure a retry policy. ## How the SDKs raise them | HTTP | TypeScript (`@parmana/sdk`) | Python (`parmana.errors`) | | --------------------------------- | ----------------------------------------------- | ------------------------------------------------ | | 403 with `POLICY_DENIED` | `ExecutionRejectedError` | `ExecutionRejectedError` | | 403 with `CAPABILITY_NOT_ALLOWED` | `AuthorizationError` (`serverCode` set) | `AuthorizationError` | | 400 | `ValidationError` | `ValidationError` | | 401 | `AuthenticationError` | `AuthenticationError` | | 403, any other | `AuthorizationError` | `AuthorizationError` | | 404 | `NotFoundError` | `NotFoundError` | | 409 | `ConflictError` | `ConflictError` | | 429 | `RateLimitError` (`retryAfterSeconds`) | `RateLimitError` (`retry_after_seconds`) | | 5xx | `InternalServerError`, the code in `serverCode` | `InternalServerError`, the code in `server_code` | Every error from a response carries its status: `statusCode` in TypeScript, `status_code` in Python. Decide on a 5xx by its code, never by the status alone. ## By status ### 400: the request is malformed | Code | Meaning and fix | | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | `AUTHORITY_REQUIRED`, `AUTHORIZATION_REQUIRED`, `INTENT_REQUIRED` | A section of the Business Transaction is missing. Build it with `createBusinessTransaction`. | | No code, `approves without a signed human approval` | The policy named cannot load ([Chapter 4](/build-book/04-policies)). | | `INVALID_EXTERNAL_CONNECTOR`, `EXTERNAL_ENDPOINT_ADDRESS_REFUSED` | A registration was refused ([Chapter 6](/build-book/06-connect-your-systems)). | | `EXECUTION_INTENT_RESOLUTION_INVALID` | `resolution` must be `NOT_EXECUTED` or `EXECUTED`, with a `note`. | ### 401: no valid key `{"error":"authentication required"}`, with a `WWW-Authenticate` header. The key is missing, wrong, rotated, or not sent as `Authorization: Bearer `. Nothing about the request was read. ### 403: not allowed | Code | Meaning and fix | | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `POLICY_DENIED` | **The policy refused.** A correct answer, with the policy's reason. If the reason asks for an approval, get one signed and send a new transaction ([Chapter 5](/build-book/05-human-approvals)). A Refusal Record was stored. | | `CAPABILITY_NOT_ALLOWED` | The key may not use this action. The operator adds it to `allowedCapabilities`. | | No code, about the principal | The key may not act for this `principalId`. Act as your own `callerId`. | | `NON_HUMAN_CALLER_DENIED` | A governance call from a key not registered as a human. | | `SAME_ACTOR_CANNOT_APPROVE_OWN_CHANGE` | The checker is the maker. A different person approves. | | `STEP_UP_AUTHORIZATION_INVALID` | The step up signature expired (120 seconds), was used, is for another change or decision, or does not match the checker's registered key. Sign again and send at once. | `POLICY_DENIED` covers every refusal by the decision, each with its own message: | Message starts with | Cause and fix | | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | The policy's own reason, for example `Refund rejected. Every refund needs ...` | A rule refused. If it asks for an approval, get one signed and send a new transaction. | | `Rejected: capability "x" requires policy "p"@"v", but ... was declared.` | The request names another policy or an old version. Read `GET /policies/in-effect` and send that. | | `Rejected: declared signal(s) do not match independently verified state (managerApproved=true != verified managerApproved=false).` | The approval is missing, expired, used, by an untrusted key, for another resource, or below the amount. Sign a new one. | | `Rejected: declared signal(s) do not match the executed intent (refundAmount=900 != intent.parameters.amount=750).` | A bound signal does not equal its request field. Copy the value from the request. | | `Rejected: no version of policy "p" ... has been approved through policy governance` | No approved version yet. Approve one ([Chapter 4](/build-book/04-policies)). | ### 404: not found | Code | Meaning and fix | | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | | `CAPABILITY_NOT_BOUND` | No policy is bound to the action: not a built in capability, and no active external registration. | | `POLICY_NOT_FOUND` | The policy named does not exist on the server. | | `BUSINESS_TRANSACTION_NOT_FOUND`, `EXECUTION_INTENT_NOT_FOUND` | No record with that `businessTransactionId`. | | `PENDING_POLICY_CHANGE_NOT_FOUND`, `APPROVAL_ISSUER_CHANGE_NOT_FOUND`, `EXTERNAL_CONNECTOR_CHANGE_NOT_FOUND` | No change with that id. | | `VERIFICATION_FAILED` | The record for that id could not be verified or found; the message says which. | ### 409: conflict | Code | Meaning and fix | | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `DUPLICATE_BUSINESS_TRANSACTION` | This `businessTransactionId` was already used. To retry the same attempt, read its record; for a new attempt, build a new transaction. | | `NONCE_ALREADY_CONSUMED` | This exact authorization was already released: the action ran. Read its record; do not send it again as a new transaction. | | `NO_APPROVED_POLICY_VERSION` | No version of the bound policy is approved. Every request is refused until one is. | | `CONFLICT` | A change is already open, or already resolved, or a registration is already active. | | `EXECUTION_INTENT_NOT_RESOLVABLE` | The intent is `RELEASED` (finalize it) or already final. | | `EXECUTION_INTENT_RESULT_NOT_RECORDED` | Finalize has nothing to build from: the intent is not `RELEASED`. | ### 429: too many requests `RATE_LIMITED`. Wait for the `Retry-After` header, then retry the same request. ### 500, 502: the action may have run | Code | Meaning | Do | | ----------------------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `EXECUTION_RECORD_INCOMPLETE` | The action was released; the signed record was not stored. | Do not resend. `POST /execution-intents/{id}/finalize` rebuilds the record without acting again. | | `EXECUTION_OUTCOME_UNKNOWN` | The release failed after it may have reached the system: a timeout, an error, an answer that failed the checks. | **Never resend as a new transaction.** Check the system with the `businessTransactionId` and `target`, then `POST /execution-intents/{id}/resolve` with what you found. | For an external connector, a request naming a parameter the registration does not allow, or an endpoint whose host now resolves to a non public address, is also reported as `502 EXECUTION_OUTCOME_UNKNOWN` although nothing was sent. Check the endpoint's own record before resolving. ### 503: unavailable, nothing ran | Code | Meaning | | ------------------------------ | ---------------------------------------------------------------------------------------- | | `CONNECTOR_NOT_REGISTERED` | No connector for the action: not configured, or the external registration is not active. | | `SIGNING_UNAVAILABLE` | The signing path is unhealthy. Nothing is released until it is fixed. | | `EXECUTION_INTENT_UNAVAILABLE` | The intent store is unavailable (or its table is missing after an upgrade). | | `POLICY_VERSION_UNAVAILABLE` | The server could not read the policy in effect. | | `AUDIT_UNAVAILABLE` | The audit store is unavailable. | Retry later with a **new** transaction. If it persists, tell the operator; each is an alert in [Chapter 8](/build-book/08-deploy-and-operate). ## Common situations | You see | Cause | Fix | | --------------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | | `403 POLICY_DENIED`, `requires policy ... but ... was declared` | The agent sent an old policy version. | Read `GET /policies/in-effect` before each request; never hard code a version. | | Every request refused after a policy change was proposed | The new version is not approved, or the agent names a version not in effect. | Approve it, then read the version in effect. | | A server that will not start | A required setting is missing or wrong; the message names the variable. | [Environment variable reference](/deployment/environment-variables). | | `PGRST205` "Could not find the table" | The database's schema cache is stale. | `NOTIFY pgrst, 'reload schema';` | More, by symptom: [Troubleshooting](/troubleshooting). --- ## TypeScript SDK Source: https://docs.parmanasystems.com/sdks/typescript **[AVAILABLE]**, **published**, [`@parmana/sdk` on npm](https://www.npmjs.com/package/@parmana/sdk). Real, public, installable with a plain `npm install @parmana/sdk`. This page said otherwise ("private, not published to any registry") until 2026-09-14; that was stale even at v1.1.1, confirmed live against the npm registry while fixing this page. Bearer-key authentication, a real thrown error taxonomy, and full test coverage are all in place, along with corrected model types (see below), `client.verify()`/`client.version()`, and `client.createTransaction()`. v1.6.0, published 2026-10-11, is the current version. It adds the business signal source and authority grant methods, and `conditions` on a release: see [New in 1.6.0](#new-in-160). v1.5.0, published 2026-10-04, added the external connector methods, `policyInEffect()` and `verifyParmanaRelease()`, and the Apache License 2.0: see [New in 1.5.0](#new-in-150). v1.4.0, published 2026-09-29, added `signApproval()` and the approver methods: see [New in 1.4.0](#new-in-140). v1.3.0, published 2026-09-25, added policy governance with step up signing, offline verification, `caller()`, `publicKey()`, `trustRecords()`, `latestReceipt()` and `statusCode` on errors: see [New in 1.3.0](#new-in-130). v1.2.0, published 2026-09-21, added the Execution Intent methods. v1.1.6, published 2026-09-20, builds a default `HttpTransport` when you omit `transport`, and sends `userAgent`. v1.1.3 added `serverCode` on `InternalServerError` (see below). v1.1.2, published 2026-09-14, added `createBusinessTransaction()`. See the fix history at the bottom of this page. v1.1.2's published package metadata (`homepage`/`repository`/`bugs` in `npm view @parmana/sdk@1.1.2`) still points at this repository's old name, `parmana-exp`, because registry metadata is immutable per version. v1.1.3 and later point at `parmana`. Neither affects installing or using the package. Package name history, for anyone who finds an old reference: renamed from `@parmana/legacy-reference`, itself renamed from `@parmana/typescript-sdk`. See [Changelog](/changelog) for when. `@parmana/sdk` is the current, correct, published name. ## Install ```bash npm install @parmana/sdk ``` The SDK is licensed under the Apache License 2.0. It is a client and needs a Parmana server, which is licensed separately. See [License and evaluation](/license). Real registry install. `npm view @parmana/sdk version` returns the current published version directly from npm, confirmed while fixing this page's own stale claim otherwise. ## Bearer-key authentication `Configuration.apiKey` is sent as `Authorization: Bearer ` on every request, by `HttpTransport`: ```typescript import { ParmanaClient, HttpTransport } from "@parmana/sdk"; const endpoint = "http://localhost:3000"; const apiKey = "my-secret-api-key"; const client = new ParmanaClient({ endpoint, apiKey, transport: new HttpTransport({ endpoint, apiKey }), }); const health = await client.health(); // GET /health, no key required ``` `apiKey` is optional in `Configuration`, for the same reason it's optional server-side: local development against a server started with `PARMANA_AUTH_DISABLED=true` needs no key. Every real deployment requires one. An omitted or wrong key gets a real `401`, thrown as `AuthenticationError` (see below), not a silent failure. See [Authentication](/api-reference/authentication) for how keys are minted. ## Errors, they actually throw now `HttpTransport.send()` checks `response.status` and throws the matching typed error for every non-2xx response, built from the real `{error, code?}` envelope (`packages/api/src/middleware/error-handler.ts`): ```typescript import { ValidationError, AuthenticationError, AuthorizationError, NotFoundError, ConflictError, ExecutionRejectedError, InternalServerError, NetworkError, TimeoutError, } from "@parmana/sdk"; try { await client.execute(transaction); } catch (error) { if (error instanceof ExecutionRejectedError) { console.log(error.message); // "Execution rejected: " } else if (error instanceof ValidationError) { // 400, malformed request } } ``` | Status | Exception | | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | 400 | `ValidationError` | | 401 | `AuthenticationError` | | 403, code `POLICY_DENIED`, message starting `Execution rejected` | `ExecutionRejectedError` | | 403 (no `code`) | `AuthorizationError` | | 404 | `NotFoundError` | | 409 | `ConflictError` | | any other non-2xx | `InternalServerError`, with the server's `code` on `serverCode` (1.1.3, see below) | | connection failure | `NetworkError` | | request timeout | `TimeoutError` | ### Telling the 5xx signing and record errors apart (1.1.3) `InternalServerError.serverCode` carries the Runtime's own `code` when the response had one. Two values change what you should do next: - `SIGNING_UNAVAILABLE` (503): the Runtime refused before releasing the action, because it could not sign an Execution Trust Record. **Nothing was executed.** Retry once signing is healthy, with a new `businessTransactionId`, because the original was already accepted. - `EXECUTION_INTENT_UNAVAILABLE` (503): the Runtime refused before releasing the action, because it could not sign and store the [Execution Intent](/concepts/execution-intents). **Nothing was executed.** Retry once healthy, with a new `businessTransactionId`. - `EXECUTION_RECORD_INCOMPLETE` (500): the action **was** released to the execution system, but its signed record could not be produced. **Do not retry as a new transaction.** Tell the operator: a signed Execution Intent exists, and the operator can rebuild the record without repeating the action. The method is `finalizeExecutionIntent`. It is available from version 1.2.0. On 1.1.6 and earlier the operator uses `POST /execution-intents//finalize` over HTTP. - `EXECUTION_OUTCOME_UNKNOWN` (502, since 2026-09-25): the action was released and the connector call failed, so it **may** have been performed. **Do not retry as a new transaction.** Tell the operator, who checks the target system and closes the intent with `resolveExecutionIntent`. ```typescript try { await parmana.execute(transaction); } catch (error) { if (error instanceof InternalServerError) { if (error.serverCode === "EXECUTION_RECORD_INCOMPLETE") { // The action ran. Reconcile, never resubmit under a new id. } else if (error.serverCode === "EXECUTION_OUTCOME_UNKNOWN") { // The action may have run. Reconcile, never resubmit under a new id. } else if ( error.serverCode === "SIGNING_UNAVAILABLE" || error.serverCode === "EXECUTION_INTENT_UNAVAILABLE" ) { // Nothing ran. Safe to retry later with a NEW businessTransactionId. } } throw error; } ``` `serverCode` is available in v1.1.3 and later, published 2026-09-20. On v1.1.2 and earlier it is not on `InternalServerError`, and you can read the code from the error message instead. All extend `ParmanaError`, which carries a stable `code` (`ErrorCode` enum) and optional `cause`/`requestId`. `403` (`AuthorizationError`) is a real status this API returns: an authenticated caller asserting an `authority.principalId` it isn't permitted to assert (`isPrincipalAllowed`, `packages/api/src/routes/execute.ts`), documented in the [Error catalog](/api-reference/error-catalog) and the OpenAPI spec. **One route is deliberately exempt from this mapping.** `POST /policies/validate` never uses the shared `{error, code?}` envelope: every status it returns (`200`, `400`, `404`) is `{valid, errors}`, the caller's answer, not an SDK-level failure. `PolicyApi.validate` opts those two statuses out via `TransportRequest.nonThrowingStatuses`; a `401` on the same route still throws `AuthenticationError`, since that one is generated by caller-auth middleware before the route's own handler runs, using the shared envelope like every other route. See [Error handling](/api-reference/error-handling). ## Model types, matched to the real schemas `typescript/src/models/*.ts` is hand-maintained, unlike the Python SDK's generated models, and kept in sync field-for-field against the real JSON schemas (`schemas/common/*.schema.json`): - `Authority.displayName` and `Verification.message` are optional, matching the real API. - `Authorization.expiresAt` is present (optional). - `BusinessTransactionMetadata` requires only `businessTransactionId`; every other field (`tenantId`, `correlationId`, `sourceSystem`, `submittedBy`, `submittedAt`) is optional, matching the real API. - `ExecutionTrustRecord` includes the required `signature` field and the optional `settlementConfirmations` field, with their own `SettlementConfirmation`/`Signature` models. - `Override.approvedBy` is the real field name (the real schema has no `authorityId` on `Override`), plus the optional `justification` field. Currently unreachable in practice, no route in this repository creates an `Override`. - `Decision.outcome`, `Execution.status`, `Execution.mode`, and `BusinessTransaction.status` are literal unions matching the real enums, not bare `string`. - `Execution` includes `completedAt`, `evidence` (where Connector evidence lives), and `metadata`. ## `client.verify()` and `client.version()` - **`client.verify(businessTransactionId)`**: `POST /verify`, runs a fresh verification and appends a new `Verification` to the record's history. `getLatestVerification()` (`GET /verification/:id`) reads a cached verification; this triggers a fresh one. - **`client.version()`**: `GET /version`. ## `POST /transactions` `client.createTransaction(transaction)` is a second, independent entry point into the identical execution pipeline as `execute()` (`POST /execute`), differing only in its `201` status code. It's a stable, long-standing route with dedicated test coverage (`packages/api/tests/unit/transactions-api.test.ts`), not a new or evolving surface. ## `createBusinessTransaction()`, no more hand-synced ids Added 2026-09-14, ships in v1.1.2, published, available via a plain `npm install @parmana/sdk`. A `BusinessTransaction` has three id pairs the server's own `BusinessTransactionValidator` cross-checks before policy ever runs: `metadata.businessTransactionId` must equal `businessTransactionId`, `authorization.authorityId` must equal `authority.authorityId`, `intent.authorizationId` must equal `authorization.authorizationId`. Every "X must match Y" `400` documented in [the end-to-end Paytm guide](/guides/end-to-end-paytm-flow) came from hand-building a request object and getting one of those wrong. `createBusinessTransaction()` derives all three automatically: ```typescript import { createBusinessTransaction, ParmanaClient, HttpTransport, } from "@parmana/sdk"; const client = new ParmanaClient({ endpoint, transport: new HttpTransport({ endpoint }), }); // The approver's signed approval for this action and resource, made with // signApproval() on the approver's machine and passed to the agent. const approval = pendingApprovals.get("vendor/vendor-123"); const transaction = createBusinessTransaction({ principalId: "alice@example.com", // must match your key's allowedPrincipalIds purpose: "Vendor payment approval", action: "vendor-payment", target: "vendor/vendor-123", parameters: { amount: 100, currency: "USD" }, policy: { name: "vendor-payment", version: "2.1.0", schemaVersion: "1.0.0" }, signals: { // No agent action is authorized without a signed human approval: a trusted // approver signs one for this target up to this amount (signApproval), and // the agent attaches it. Without it the request is refused. humanApproved: true, approvalArtifact: approval, vendorVerified: true, invoiceVerified: true, paymentApproved: true, sufficientFunds: true, paymentAmount: 100, riskScore: 5, vendorId: "vendor/vendor-123", // boundSignals: SignalIntentBinder rejects this // request unless this exactly equals `target` above }, }); const trustRecord = await client.execute(transaction); ``` `businessTransactionId` defaults to a fresh `crypto.randomUUID()` if you don't supply one. A value you do supply is your own responsibility to keep unique per attempt, since it also doubles as the server's idempotency key. `authorityType` defaults to `"SERVICE"` (never `"AGENT"`, which doesn't exist server-side). See `typescript/examples/06-create-business-transaction.ts` (the same flow as `02-execute.ts`, side by side, for direct comparison) and `typescript/test/createBusinessTransaction.test.ts` (11 unit tests) / `typescript/test/integration/examples.integration.test.ts` (proves the generated ids round-trip correctly through a real running server, not just in isolation). ## New in 1.6.0 ### Register a business signal source Register the business system that owns a fact a policy needs, an ERP, an order system, a CRM, so Parmana asks it instead of trusting the agent. One person proposes, a different person approves with a step up authorization; both need a human key. ```typescript const change = await maker.proposeBusinessSignalSourceChange({ action: "register", name: "erp", endpointUrl: "https://orders.example.com/parmana/signals", publicKeyPem: readFileSync("erp-answers.public.pem", "utf8"), keyId: "erp-2026-10", reason: "Release goods only against invoices the ERP says are paid.", }); await checker.approveBusinessSignalSourceChange( change.changeId, signPolicyChangeStepUp({ pendingPolicyChangeId: change.changeId, action: "approve", privateKeyPem: readFileSync("step-up.private.pem", "utf8"), keyId: "checker-step-up-1", }), ); await checker.businessSignalSources(); // [{ name: "erp", status: "active", ... }] ``` `businessSignalSourceChanges(status?)` lists proposals and `rejectBusinessSignalSourceChange(changeId, reason, stepUp)` rejects one. To end a registration, propose `{ action: "revoke", name, reason }` and approve it the same way. The endpoint and the signed protocol are in [Connect a business signal source](/guides/connect-a-business-signal-source). ### Grant an agent authority Say which agent may have which action decided, within which limits, until when. A policy with `requireAuthorityGrant` decides a request only for a caller holding a grant in force. ```typescript const change = await maker.proposeAuthorityGrantChange({ action: "grant", callerId: "warehouse-agent", capability: "erp:release-goods", limits: { "parameters.amount": { max: 100000 }, "parameters.currency": { oneOf: ["INR"] }, }, validUntil: "2026-12-30T00:00:00.000Z", reason: "The warehouse agent releases goods for paid invoices up to 100000 INR this quarter.", }); await checker.approveAuthorityGrantChange(change.changeId, stepUp); // signed as above await checker.authorityGrants(); // [{ callerId: "warehouse-agent", status: "active", ... }] ``` `authorityGrantChanges(status?)` and `rejectAuthorityGrantChange(changeId, reason, stepUp)` work as for sources. To end a grant, propose `{ action: "revoke", callerId, capability, reason }`. No one grants authority to themselves. See [Grant an agent authority](/guides/grant-an-agent-authority). ### Conditions on a release A release to an external connector now carries `conditions`, typed `ParmanaReleaseCondition[]`: the business facts the decision rests on, each with `signalKey`, `source`, `claim`, `subject`, `value` and `validUntil`. They are covered by the signature. Act only if each still holds in your records; otherwise answer `success: false` with `conditionsNotMet: true`. `typescript/examples/07-external-connector-endpoint.ts` takes a `conditionsHold` check. ## New in 1.5.0 ### What a request must carry, from the server `policyInEffect(capability)` calls `GET /policies/in-effect` and returns the policy to declare and what the request must carry: every fact the policy's rules read (`signals.facts`), their types (`signals.schema`), the signals that must equal a value of the Intent (`signals.bound`), and the signals that need a signed approval, with where the approval's resource and amount are in the request (`signals.approval`). It never returns the rules. ```typescript const inEffect = await client.policyInEffect("paytm:refund"); inEffect.policy; // { name: "customer-refund", version: "1.2.0", schemaVersion: "1.0.0" } inEffect.signals.facts; // ["fraudCheckPassed", "managerApproved", "refundAmount", "refundEligible"] inEffect.signals.approval; // { managerApproved: { resourceId: "parameters.orderId", value: "parameters.amount" } } ``` Call it before every request and send `policy` exactly as returned. It throws `AuthorizationError` (403, `CAPABILITY_NOT_ALLOWED`), `NotFoundError` (404, `CAPABILITY_NOT_BOUND`), `ConflictError` (409, `NO_APPROVED_POLICY_VERSION`) or `InternalServerError` (503, `POLICY_VERSION_UNAVAILABLE`); in each case send nothing. The step by step use is in [Integrate Parmana: specification for AI agents](/agents/integrate), step 3. ### Register an external connector Connect your own system with no Parmana code change: one person proposes registering a capability to your HTTPS endpoint, a different person approves it with a step up authorization. Both need a human key. ```typescript const change = await maker.proposeExternalConnectorChange({ action: "register", capability: "erp:create-invoice", endpointUrl: "https://erp.example.com/parmana/release", policy: "erp-invoice", allowedParameters: ["amount", "currency"], reason: "Finance creates invoices in the ERP through Parmana.", }); await checker.approveExternalConnectorChange( change.changeId, signPolicyChangeStepUp({ pendingPolicyChangeId: change.changeId, action: "approve", privateKeyPem: readFileSync("step-up.private.pem", "utf8"), keyId: "checker-step-up-1", }), ); await checker.externalConnectors(); // [{ capability: "erp:create-invoice", status: "active", ... }] ``` `externalConnectorChanges(status?)` lists proposals and `rejectExternalConnectorChange(changeId, reason, stepUp)` rejects one. To end a registration, propose `{ action: "revoke", capability, reason }` and approve it the same way. Tutorial 123 runs these calls against a real server. The whole path, with the endpoint, is in [Connect any external system](/guides/connect-any-external-system). ## New in 1.4.0 Published to npm on 2026-09-29. `npm install @parmana/sdk` installs it. ### Sign an approval on the approver's machine `signApproval()` makes the signed approval a policy with `approvalSignals` asks for, the same one `scripts/sign-approval.ts` makes. The private key never leaves the process. ```typescript import { readFileSync } from "node:fs"; import { signApproval } from "@parmana/sdk"; const approval = signApproval({ privateKeyPem: readFileSync( "manager-priya__manager-priya-key-1.private.pem", "utf8", ), approverId: "manager-priya", keyId: "manager-priya-key-1", capability: "paytm:refund", resourceId: "ORD-1042", maxAmount: 75000, // leave out when the policy declares no amount }); // The agent sends it in signals.approvalArtifact on a new request. ``` The approval is valid for 15 minutes by default (`ttlSeconds`, up to a day) and can be used once. See [Human approval](/concepts/human-approval). ### Manage approvers Add, rotate and revoke the keys trusted to sign approvals, through maker checker, with no deploy. Every call needs an API key added as a human. ```typescript import { ParmanaClient, signPolicyChangeStepUp } from "@parmana/sdk"; // The proposer const change = await proposer.proposeApproverChange({ action: "add", approverId: "manager-priya", keyId: "manager-priya-key-1", publicKeyPem, // the approver's .public.pem file reason: "Priya approves refunds for the West region from October.", }); // A different person, the checker const pending = await checker.approverChanges("PENDING_APPROVAL"); await checker.approveApproverChange( change.changeId, signPolicyChangeStepUp({ pendingPolicyChangeId: change.changeId, action: "approve", privateKeyPem: checkerStepUpKeyPem, keyId: "checker-step-up-1", }), ); const approvers = await checker.approvers(); ``` `rejectApproverChange(changeId, reason, stepUp)` rejects one, and `action: "revoke"` proposes a revocation. See [Manage approvers](/guides/manage-approvers). ## New in 1.3.0 Published to npm on 2026-09-25. `npm install @parmana/sdk` installs it. On 1.2.0 and earlier none of this section exists. The full list of what each version covers is on [SDK coverage of the API](/sdks/api-coverage). 1.3.0 makes the TypeScript SDK cover the same operations as the Python SDK, and adds policy governance to both. ### Who the key is, and the deployment's public key ```typescript import { ParmanaClient } from "@parmana/sdk"; const client = new ParmanaClient({ endpoint: "http://127.0.0.1:3000", apiKey: "pk_local_...", }); const me = await client.caller(); // { callerId: "local-operator", allowedPrincipalIds: ["local-operator"], allowedCapabilities: ["paytm:refund"], unrestrictedCapabilities: false } const key = await client.publicKey("default"); // key.pem is the SPKI PEM that signs Trust Records, Refusal Records and Execution Intents ``` `publicKey()` needs no API key. Fetch it once and keep it. ### Verify records offline ```typescript import { ParmanaClient, verifyExecutionIntentOffline, verifyExecutionTrustRecordOffline, } from "@parmana/sdk"; const client = new ParmanaClient({ endpoint: "http://127.0.0.1:3000", apiKey: "pk_local_...", }); const { pem } = await client.publicKey("default"); const record = await client.trustRecord("5015499d-b7ba-48fe-a9ad-821862d70ad0"); const result = verifyExecutionTrustRecordOffline(record, { default: pem }); // { valid: true, hashValid: true, legacySignatureValid: true, algorithmsChecked: ["ed25519"], errors: [] } const view = await client.executionIntent( "5015499d-b7ba-48fe-a9ad-821862d70ad0", ); verifyExecutionIntentOffline(view.intent, { default: pem }); ``` No network call and no database: only the record and the public key. The result has the same fields as the Python SDK's, in camelCase. A record that also carries a hybrid ML-DSA-65 `signatures` array is reported as not valid, with an error that says why, because the SDKs do not verify ML-DSA-65. ### Check a release at an external connector endpoint (1.5.0) An endpoint registered as an external connector receives each approved request as a signed release. It checks the release before it acts: ```typescript import { verifyParmanaRelease } from "@parmana/sdk"; const result = await verifyParmanaRelease(body, { publicKeys: { default: pem }, // client.publicKey("default") audience: "https://erp.example.com/parmana/release", // this endpoint, as registered isAlreadyExecuted: (businessTransactionId) => answers.has(businessTransactionId), }); if (!result.valid) { // Act on nothing. result.errors says why. } else if (result.alreadyExecuted) { // Answer with the first result again; do not act twice. } else { // Act on result.release.capability, target and parameters only, then answer // { businessTransactionId, capability, success, result, executedAt }. } ``` It checks, in order, the body's shape, the signature over the canonical release (including the commitment a KMS signed release over 4096 bytes uses), that the release was made for this endpoint, and that it has not expired (30 seconds of clock skew by default). A whole endpoint built on it is `typescript/examples/07-external-connector-endpoint.ts`; Tutorial 123 (`examples/tutorials/123-external-connector/run.ts`) registers it and releases to it. ### Approve a policy change The approver signs on their own machine; the private key never leaves it. ```typescript import { readFileSync } from "node:fs"; import { ParmanaClient, signPolicyChangeStepUp } from "@parmana/sdk"; const endpoint = "http://127.0.0.1:3000"; const proposer = new ParmanaClient({ endpoint, apiKey: "pk_local_... (alice)", }); const approver = new ParmanaClient({ endpoint, apiKey: "pk_local_... (bob)" }); const change = await proposer.proposePolicyChange("customer-refund", "1.2.0", { proposedContent: JSON.parse( readFileSync("policies/customer-refund/1.2.0/policy.json", "utf8"), ), reason: "Adopt the shipped customer-refund policy.", }); const open = await approver.policyChanges("PENDING_APPROVAL"); // each entry has diff.current and diff.proposed const stepUp = signPolicyChangeStepUp({ pendingPolicyChangeId: change.pendingPolicyChangeId, action: "approve", privateKeyPem: readFileSync("approver/step-up.private.pem", "utf8"), keyId: "bob", }); const approved = await approver.approvePolicyChange( change.pendingPolicyChangeId, stepUp, ); // approved.status === "APPROVED" ``` To reject, sign with `action: "reject"` and call `rejectPolicyChange(id, "Why it is rejected.", stepUp)`. A signed authorization is valid once, for one change and one action, for 120 seconds unless you pass `ttlSeconds`. The rules and every error are on [Approve a policy](/self-hosted/policy-approval). ### List Trust Records, and the latest receipt ```typescript import { ParmanaClient } from "@parmana/sdk"; const client = new ParmanaClient({ endpoint: "http://127.0.0.1:3000", apiKey: "pk_local_...", }); const records = await client.trustRecords(1, 25, { since: "2026-09-01T00:00:00Z", }); const receipt = await client.latestReceipt( "5015499d-b7ba-48fe-a9ad-821862d70ad0", ); ``` `trustRecords()` returns only records of transactions this key submitted, newest first. A page counts transactions, so it may hold fewer records than `pageSize`. `pageSize` is at most 100; a larger value is refused with `400`. `latestReceipt()` reads the most recent receipt without generating a new one, unlike `receipt()`. ### Every HTTP error carries its status ```typescript import { InternalServerError, ParmanaClient } from "@parmana/sdk"; const client = new ParmanaClient({ endpoint: "http://127.0.0.1:3000", apiKey: "pk_local_...", }); try { await client.execute(transaction); // a BusinessTransaction, see createBusinessTransaction() } catch (error) { if ( error instanceof InternalServerError && error.serverCode === "CONNECTOR_NOT_REGISTERED" ) { // error.statusCode === 503: authorized, but nothing was executed } } ``` `statusCode` is set on every error made from an HTTP response, matching the Python SDK's `status_code`. It is absent on network errors and timeouts, where no response arrived. ## Building from source / contributing Installing the published package (above) is the right path for using the SDK. To build from source inside this monorepo instead: ```bash npm install --workspace=typescript npm run build # typescript/ npm run test # vitest ``` ## Test suite 191 tests across 18 files (2026-09-25, 1.3.0). **Run `npx vitest run` from the repo root**, not `typescript/`: several tests read `PARMANA_POLICY_DIR` relative to `process.cwd()` and fail with confusing `PolicyNotFoundError`-shaped errors otherwise. Coverage includes: unit tests for every error-mapping case (`test/HttpTransport.test.ts`, `test/Errors.test.ts`), unit tests for `createBusinessTransaction()` (`test/createBusinessTransaction.test.ts`, 11 cases), unit tests for `client.verify()`/`client.version()`/`client.createTransaction()` (`test/NewApiMethods.test.ts`), a retry-logic suite (`test/RetryPolicy.test.ts`, real backoff on idempotent GETs against 502/503/504, POSTs never retried, matching the Python SDK's own retry behavior), per-route unit suites (`test/AuditApi.test.ts`, `test/HealthApi.test.ts`, `test/PolicyApi.test.ts`, `test/RefusalApi.test.ts`, `test/ReplayApi.test.ts`, `test/RuntimeApi.test.ts`, `test/VerificationApi.test.ts`), and two integration suites that boot the actual `@parmana/api` Express application: real `StaticKeyAuthenticator`, real `PolicyEngine`, real Ed25519 signing, the real, `NODE_ENV=test`-only generic test-fixture connector (`createTestFixtureConnector.ts`, successor to the now-removed `vendor-payment`), on a real OS-assigned TCP port, driven by the real `ParmanaClient` over real HTTP: `test/integration/parmana-client.integration.test.ts` (a real `401`, a real `403`, a real `400`, a real policy rejection, a real `404`, a real `409` duplicate, plus `version()`, `verify()`, and `createTransaction()`, each asserted against the exact typed result and exact message the server returned) and `test/integration/examples.integration.test.ts` (runs the quickstart example script itself, see [Changelog](/changelog)). `test/Alignment.test.ts` covers everything added in 1.3.0, including offline verification of a real server signed Execution Intent and a step up authorization accepted by the server's own verifier. ## Errors defined but never thrown `typescript/src/errors/` also declares `VerificationError` and `ReplayError`. Neither is thrown anywhere: no verified HTTP condition in this API corresponds to them (`POST /verify` and `POST /replay` return their semantic result, `VERIFIED`/`FAILED`, or a replay outcome, inside an ordinary `200`, not as an error). They remain defined, unused: inventing a throw condition for them would describe behavior the real API doesn't have. ## Next The full, no-ambiguity runbook this SDK builds toward: real infrastructure, every error message, every fix. The other maintained SDK: same bearer-key model, generated (not hand-maintained) models. Every error the real API returns, independent of any SDK. Every class, interface, and type, generated from the real JSDoc, in the sidebar under SDKs. --- ## TypeScript SDK configuration and behavior Source: https://docs.parmanasystems.com/sdks/typescript-configuration This page states exactly how `@parmana/sdk` behaves. If a guide and this page disagree, this page is right, and please report the guide. Every value here was read from the SDK source, and the retry behavior was tested against a server that fails on purpose. ## Create a client ```typescript import { ParmanaClient, type Configuration } from "@parmana/sdk"; const configuration: Configuration = { endpoint: "https://your-parmana.example.com", apiKey: "your API key", }; const client = new ParmanaClient(configuration); ``` From 1.1.6 the client builds a default `HttpTransport` from the configuration you pass, so `timeout`, `retryPolicy`, `apiKey` and `userAgent` all apply. Two notes: 1. **On 1.1.5 and earlier, always pass `transport`.** Those versions throw `ConfigurationError: Transport is required.` when it is missing, even though the `Configuration` type marks it optional. Use 1.1.6 or later to omit it. 2. **If you supply your own transport, build it from the same configuration object.** `HttpTransport` reads `timeout` and `retryPolicy` from the configuration it is constructed with, not from the client. If you write `new HttpTransport({ endpoint, apiKey })` next to a `retryPolicy` on the client, the transport never sees the retry policy and **nothing is retried**, with no error. Omit `transport` and this cannot happen. ## Configuration options | Option | Type | Default | What it does | | ------------- | -------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `endpoint` | string | None, required | The base URL of the Parmana server, without a trailing slash. Every path is appended to it as written. An empty value throws `ConfigurationError: Runtime endpoint is required.` | | `apiKey` | string | None | When set, every request carries `Authorization: Bearer `. When it is not set, no `Authorization` header is sent and a server with authentication on answers `401`. | | `timeout` | number, milliseconds | `30000` | A request that has not finished in this time is aborted and throws `TimeoutError`. Read by the transport, see rule 2 above. | | `retryPolicy` | `RetryPolicy` | Retries off | See the next section. Read by the transport, see rule 2 above. | | `transport` | `Transport` | A default `HttpTransport` | The object that sends requests. Omit it in 1.1.6 and later. Required in 1.1.5 and earlier. | | `userAgent` | string | None | Sent as the `User-Agent` header from 1.1.6. Before 1.1.6 it was declared but never sent. | Every request also carries `Content-Type: application/json`. ## Retries Retries are **off by default**. They run only when both `retryPolicy.enabled` is `true` and `maxAttempts` is above zero. | `RetryPolicy` field | Default | Meaning | | ------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------- | | `enabled` | `false` | Must be `true` for any retry to happen. | | `maxAttempts` | `0` | The number of retries after the first attempt. `3` makes up to 4 requests in total. | | `initialDelayMs` | `1000` | The delay before the first retry. | | `maxDelayMs` | `30000` | The ceiling for any delay. | | `strategy` | `RetryStrategy.EXPONENTIAL` | `EXPONENTIAL` doubles the delay on each retry, up to `maxDelayMs`. `FIXED` always waits `initialDelayMs`. | What is retried: | Request | Retried? | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | `GET` requests (`health()`, `version()`, `transaction()`, `transactions()`, `trustRecord()`, `getLatestVerification()`, `refusalRecord()`, `executionIntent()`, `unfinalizedExecutionIntents()`, and from 1.3.0 `latestReceipt()`, `trustRecords()`, `caller()`, `publicKey()`, `policyChanges()`, from 1.5.0 `policyInEffect()`, `externalConnectors()`, `externalConnectorChanges()`, and from 1.6.0 `businessSignalSources()`, `businessSignalSourceChanges()`, `authorityGrants()`, `authorityGrantChanges()`) | Yes, on a network failure, a timeout, a `429` or a `5xx`. Nothing else. | | `POST` requests (`execute()`, `createTransaction()`, `verify()`, `replay()`, `receipt()`, `validatePolicy()`, `verifyRefusalRecord()`, `verifyExecutionIntent()`, `finalizeExecutionIntent()`, `resolveExecutionIntent()`, `verifyAuditEvent()`, from 1.3.0 `proposePolicyChange()`, `approvePolicyChange()`, `rejectPolicyChange()`, from 1.4.0 `proposeApproverChange()`, `approveApproverChange()`, `rejectApproverChange()`, from 1.5.0 `proposeExternalConnectorChange()`, `approveExternalConnectorChange()`, `rejectExternalConnectorChange()`, and from 1.6.0 `proposeBusinessSignalSourceChange()`, `approveBusinessSignalSourceChange()`, `rejectBusinessSignalSourceChange()`, `proposeAuthorityGrantChange()`, `approveAuthorityGrantChange()`, `rejectAuthorityGrantChange()`) | **Never.** This is deliberate, because retrying `execute()` could repeat a real action. | Tested: a server that returns `503` twice and then `200`, and a client with `enabled: true` and `maxAttempts: 3`, made 3 requests and returned success. With a supplied transport built from a partial configuration the same client made 1 request and threw `InternalServerError`. ## Errors Every non 2xx response, and every failure to get one, is thrown as a typed error. There is no path where a failure looks like success. | What happened | Error class | Notes | | --------------------------------------------- | ------------------------ | ----------------------------------------------------------------- | | HTTP `400` | `ValidationError` | The request is malformed. | | HTTP `401` | `AuthenticationError` | The key is missing or wrong. | | HTTP `403` with code `POLICY_DENIED` | `ExecutionRejectedError` | A policy refused. This is final. | | HTTP `403` with code `CAPABILITY_NOT_ALLOWED` | `AuthorizationError` | The server's code is on `serverCode`. | | HTTP `403` with no code | `AuthorizationError` | The key may not assert that principal. | | HTTP `404` | `NotFoundError` | | | HTTP `409` | `ConflictError` | The `businessTransactionId` was already used. | | HTTP `429` | `RateLimitError` | `retryAfterSeconds` carries the `Retry-After` value when present. | | Any other non 2xx, including `500` and `503` | `InternalServerError` | From 1.1.3, `serverCode` carries the server's code, see below. | | The connection failed | `NetworkError` | | | The request timed out | `TimeoutError` | | | A missing `endpoint` | `ConfigurationError` | Thrown by the constructor. | Several `InternalServerError` cases need different handling, and from 1.1.3 you can tell them apart with `error.serverCode`: | `serverCode` | Meaning | Do | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SIGNING_UNAVAILABLE` (503) | Refused before release. **Nothing was executed.** | Retry later with a new `businessTransactionId`. | | `EXECUTION_INTENT_UNAVAILABLE` (503) | Refused before release, because the signed [Execution Intent](/concepts/execution-intents) could not be stored. **Nothing was executed.** | Retry later with a new `businessTransactionId`. | | `CONNECTOR_NOT_REGISTERED` (503) | The action has no connector on this server. **Nothing was executed.** | Ask the operator to configure the connector, then retry with a new `businessTransactionId`. | | `EXECUTION_RECORD_INCOMPLETE` (500) | The action **was** released but its signed record could not be produced. | **Do not resubmit.** Tell the operator, who can rebuild the record with `POST /execution-intents//finalize`. The method is `finalizeExecutionIntent`, available from 1.2.0. | | `EXECUTION_OUTCOME_UNKNOWN` (502) | The action was released and the connector call failed, so it **may** have been performed. The server sends this since 2026-09-25; before, the same failure was a `500` with no code. | **Do not resubmit.** Tell the operator, who checks the target system and closes the Execution Intent with resolve. | On 1.1.2 and earlier `serverCode` is not on `InternalServerError`, so read the code from the error message. ## Methods | Method | Request | Returns | | ------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `execute(transaction)` | `POST /execute` | The signed Execution Trust Record. | | `createTransaction(transaction)` | `POST /transactions` | The same record, from the second entry point. | | `verify(id)` | `POST /verify` | A `Verification`. | | `getLatestVerification(id)` | `GET /verification/{id}` | The latest `Verification`. | | `replay(id)` | `POST /replay` | A `ReplayResult`. | | `receipt(id)` | `POST /receipt` | A `Receipt`. | | `latestReceipt(id)` (1.3.0) | `GET /receipt/latest/{id}` | The most recent `Receipt`, without generating a new one. | | `transaction(id)` | `GET /transactions/{id}` | One `BusinessTransaction`. | | `transactions(page, pageSize)` | `GET /transactions` | A page of transactions. The defaults are page `1` and page size `25`. | | `trustRecord(id)` | `GET /trust-records/{id}` | The Execution Trust Record. | | `trustRecords(page, pageSize, { since, until })` (1.3.0) | `GET /trust-records` | This caller's Execution Trust Records, newest first. A page counts transactions, so it may hold fewer records. | | `validatePolicy(policyId, policyVersion)` | `POST /policies/validate` | A `PolicyValidationResult`. | | `proposePolicyChange(name, version, { proposedContent, reason })` (1.3.0) | `POST /policies/{name}/{version}/pending-changes` | A `ProposedPolicyChange`. Needs a verified human credential. | | `policyChanges(status?)` (1.3.0) | `GET /policies/pending-changes` | `PolicyChangeForReview[]`, each with `diff.current` and `diff.proposed`. Needs a verified human credential. | | `approvePolicyChange(id, stepUpAuthorization)` (1.3.0) | `POST /policies/pending-changes/{id}/approve` | The `PendingPolicyChange`, `APPROVED`. Needs a verified human with a step up key who is not the proposer. | | `rejectPolicyChange(id, rejectionReason, stepUpAuthorization)` (1.3.0) | `POST /policies/pending-changes/{id}/reject` | The `PendingPolicyChange`, `REJECTED`. Same caller rules. | | `approvers()` (1.4.0) | `GET /approval-issuers` | `ApprovalIssuer[]`: keys in code and keys added through approver changes, revoked ones marked. Needs a verified human credential. | | `proposeApproverChange({ action, approverId, keyId, publicKeyPem?, reason })` (1.4.0) | `POST /approval-issuers/changes` | An `ApprovalIssuerChange`, `PENDING_APPROVAL`. See [Manage approvers](/guides/manage-approvers). | | `approverChanges(status?)` (1.4.0) | `GET /approval-issuers/changes` | `ApprovalIssuerChange[]`, newest first. | | `approveApproverChange(changeId, stepUpAuthorization)` (1.4.0) | `POST /approval-issuers/changes/{id}/approve` | The change, `APPROVED` and applied. Sign the step up with the change id as `pendingPolicyChangeId`. | | `rejectApproverChange(changeId, rejectionReason, stepUpAuthorization)` (1.4.0) | `POST /approval-issuers/changes/{id}/reject` | The change, `REJECTED`. | | `externalConnectors()` (1.5.0) | `GET /external-connectors` | `ExternalConnector[]`, active and revoked. Needs a verified human credential. | | `proposeExternalConnectorChange({ action, capability, endpointUrl?, policy?, allowedParameters?, timeoutMs?, reason })` (1.5.0) | `POST /external-connectors/changes` | An `ExternalConnectorChange`, `PENDING_APPROVAL`. See [Connect any external system](/guides/connect-any-external-system). | | `externalConnectorChanges(status?)` (1.5.0) | `GET /external-connectors/changes` | `ExternalConnectorChange[]`, newest first. | | `approveExternalConnectorChange(changeId, stepUpAuthorization)` (1.5.0) | `POST /external-connectors/changes/{id}/approve` | The change, `APPROVED` and applied. Sign the step up with the change id as `pendingPolicyChangeId`. | | `rejectExternalConnectorChange(changeId, rejectionReason, stepUpAuthorization)` (1.5.0) | `POST /external-connectors/changes/{id}/reject` | The change, `REJECTED`. | | `businessSignalSources()` (1.6.0) | `GET /business-signal-sources` | `BusinessSignalSource[]`, active and revoked. Needs a verified human credential. | | `proposeBusinessSignalSourceChange({ action, name, endpointUrl?, timeoutMs?, publicKeyPem?, keyId?, reason })` (1.6.0) | `POST /business-signal-sources/changes` | A `BusinessSignalSourceChange`, `PENDING_APPROVAL`. See [Connect a business signal source](/guides/connect-a-business-signal-source). | | `businessSignalSourceChanges(status?)` (1.6.0) | `GET /business-signal-sources/changes` | `BusinessSignalSourceChange[]`, newest first. | | `approveBusinessSignalSourceChange(changeId, stepUpAuthorization)` (1.6.0) | `POST /business-signal-sources/changes/{id}/approve` | The change, `APPROVED` and applied. Sign the step up with the change id as `pendingPolicyChangeId`. | | `rejectBusinessSignalSourceChange(changeId, rejectionReason, stepUpAuthorization)` (1.6.0) | `POST /business-signal-sources/changes/{id}/reject` | The change, `REJECTED`. | | `authorityGrants()` (1.6.0) | `GET /authority-grants` | `AuthorityGrant[]`, active and revoked. Needs a verified human credential. | | `proposeAuthorityGrantChange({ action, callerId, capability, limits?, validFrom?, validUntil?, reason })` (1.6.0) | `POST /authority-grants/changes` | An `AuthorityGrantChange`, `PENDING_APPROVAL`. See [Grant an agent authority](/guides/grant-an-agent-authority). | | `authorityGrantChanges(status?)` (1.6.0) | `GET /authority-grants/changes` | `AuthorityGrantChange[]`, newest first. | | `approveAuthorityGrantChange(changeId, stepUpAuthorization)` (1.6.0) | `POST /authority-grants/changes/{id}/approve` | The change, `APPROVED` and applied. Sign the step up with the change id as `pendingPolicyChangeId`. | | `rejectAuthorityGrantChange(changeId, rejectionReason, stepUpAuthorization)` (1.6.0) | `POST /authority-grants/changes/{id}/reject` | The change, `REJECTED`. | | `refusalRecord(id)` | `GET /refusal/{id}` | A `RefusalRecord`. | | `verifyRefusalRecord(record)` | `POST /refusal/verify` | `true` or `false`. | | `executionIntent(id)` | `GET /execution-intents/{id}` | An `ExecutionIntentView`: the signed intent and its status. | | `verifyExecutionIntent(intent)` | `POST /execution-intents/verify` | `true` or `false`. No credential needed. | | `unfinalizedExecutionIntents(limit?)` | `GET /execution-intents/unfinalized` | `UnfinalizedExecutionIntents`. Needs a verified human credential. | | `finalizeExecutionIntent(id)` | `POST /execution-intents/{id}/finalize` | `FinalizeExecutionIntentResult`. Never calls the connector. Needs a verified human credential. | | `resolveExecutionIntent(id, { resolution, note })` | `POST /execution-intents/{id}/resolve` | `ResolveExecutionIntentResult`. The note is required. Needs a verified human credential. | | `verifyAuditEvent(event, signature)` | `POST /audit/verify` | `true` or `false`. | | `health()` | `GET /health` | The health status. | | `version()` | `GET /version` | The version information. | | `policyInEffect(capability)` (1.5.0) | `GET /policies/in-effect` | A `PolicyInEffect`: the policy to declare, its description, and `signals` (facts, schema, bound, approval). | | `caller()` (1.3.0) | `GET /callers/me` | A `CallerIdentity`: caller ID, allowed principal IDs and capabilities. | | `publicKey(keyId = "default")` (1.3.0) | `GET /keys/{keyId}` | A `PublicKeyInfo` with the PEM, for the offline verifiers. No credential needed. | | `endpoint()` | None | The configured endpoint, as a string. | Functions exported next to the client, 1.3.0: `verifyExecutionTrustRecordOffline(record, publicKeys)`, `verifyExecutionIntentOffline(intent, publicKeys)` and `signPolicyChangeStepUp({ pendingPolicyChangeId, action, privateKeyPem, keyId, ttlSeconds? })`. From 1.5.0, `verifyParmanaRelease(body, { publicKeys, audience, isAlreadyExecuted, now?, clockSkewSeconds? })`, for an external connector endpoint to check a release from Parmana before it acts. They make no request. See [SDK coverage of the API](/sdks/api-coverage). The full signature of every method is in the [API reference](/sdks/reference/typescript/classes/ParmanaClient). ## Idempotency `businessTransactionId` is the idempotency key. Sending the same one twice returns `409` (`ConflictError`). The helper `createBusinessTransaction()` generates the identifiers and keeps the linked ones consistent, so use it instead of writing them by hand. The SDK never generates a `businessTransactionId` for you when you build the object yourself, and it never retries a `POST`, so a single call is a single attempt. ## Where to go next For a client wrapper with logging and a deployment checklist read [TypeScript SDK in production](/guides/typescript-sdk-production). For an agent that must fail closed read [TypeScript SDK for AI agents](/guides/typescript-sdk-ai-agents). For the exact rules an agent follows read [Integrate Parmana: specification for AI agents](/agents/integrate). --- ## TypeScript SDK Quickstart Source: https://docs.parmanasystems.com/guides/typescript-sdk-quickstart This walkthrough uses `test:fixture-execute`, a generic, credential-free capability built for local testing, evaluated against a policy named `vendor-payment`. That policy name is unrelated to any connector, it's just the example policy this transaction runs against. See [Quickstart](/quickstart) for the same flow driven from the repository directly rather than an installed package; this page installs `@parmana/sdk` as a real dependency the way you would in your own project. **Want to see it work before you clone anything?** Point the SDK at the public sandbox: `new ParmanaClient({ endpoint: "https://parmana-sandbox.vercel.app", apiKey: "2VfYWCzt_cBAPK-8uufX6ordfY2JuQhFPsohuEumKME", timeout: 120_000 })`. The [Playground](/playground) has a complete script for the published `@parmana/sdk`, run against the sandbox before it was published. ## Prerequisites - **Node.js** installed (`node --version`) - A running Parmana Runtime to talk to: either your own deployment, or the local in-memory server this walkthrough starts from a clone of this repository From a clone of this repository: ```bash cp .env.example .env npm ci npm run build ``` Generate the local signing keys. `keys/` is gitignored, so a fresh clone has none: the server signs with `default`, and the Execution Gateway signs its own attestations with `gateway`: ```bash npx tsx scripts/generate-keypair.ts --algorithm ed25519 --key-id default npm run generate:gateway-keys ``` No AI agent action is authorized without a signed human approval, reads included ([Human approval](/concepts/human-approval)), so you also play the approver. Make an approver key: ```bash npx tsx scripts/generate-approver-key.ts \ --approver-id local-test-approver --key-id local-test-approver-key-1 \ --out-dir ~/.parmana ``` Start the server fully locally, no external database, trusting that key (honored only with `NODE_ENV=test`): ```bash NODE_ENV=test \ PARMANA_STORAGE=memory \ PARMANA_API_KEYS='[{"callerId":"demo","keyHash":"325ededd6c3b9988f623c7f964abb9b016b76b0f8b3474df0f7d7c23b941381f","allowedCapabilities":["test:fixture-execute"]}]' \ PARMANA_TEST_APPROVER_PUBLIC_KEY_FILE=~/.parmana/local-test-approver__local-test-approver-key-1.public.pem \ npm run dev ``` Confirm it's up: ```bash curl http://localhost:3000/health # {"status":"UP"} ``` With `NODE_ENV=test`, a generic, credential-free test connector (`test-fixture`) registers automatically, no connector credentials required for this walkthrough. Every other route, including `/version`, fails closed with a `401` until you send the demo bearer key in `PARMANA_API_KEYS` above, raw key `my-secret-api-key`. It is for local development only: it may use `test:fixture-execute` and act as `demo`, and its raw key is public: ```bash curl http://localhost:3000/version -H "Authorization: Bearer my-secret-api-key" # {"name":"Parmana","version":"0.4.0","api":"v1"} ``` See [Deploy patterns](/guides/deploy-patterns) for running against something other than the in-memory local server. In a separate project (or a new terminal, same repo): ```bash npm install @parmana/sdk ``` Real, published package. `npm view @parmana/sdk version` confirms the current version directly from npm. Create `quickstart.ts`. The approver signs first (on the approver's machine in real use), then the agent sends the transaction with the approval attached: ```typescript import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { ParmanaClient, HttpTransport, createBusinessTransaction, signApproval, ExecutionRejectedError, } from "@parmana/sdk"; const endpoint = "http://localhost:3000"; const apiKey = "my-secret-api-key"; const client = new ParmanaClient({ endpoint, apiKey, transport: new HttpTransport({ endpoint, apiKey }), }); const approval = signApproval({ privateKeyPem: readFileSync( join(homedir(), ".parmana/local-test-approver__local-test-approver-key-1.private.pem"), "utf8", ), approverId: "local-test-approver", keyId: "local-test-approver-key-1", capability: "test:fixture-execute", resourceId: "vendor://payments", maxAmount: 1000, }); const transaction = createBusinessTransaction({ principalId: "demo", // the key may act only as its own caller id purpose: "Quickstart demo", action: "test:fixture-execute", target: "vendor://payments", parameters: { amount: 1000, currency: "USD" }, policy: { name: "vendor-payment", version: "2.1.0", schemaVersion: "1.0.0" }, signals: { humanApproved: true, // counts only with the signed approval below approvalArtifact: approval, vendorVerified: true, invoiceVerified: true, paymentApproved: true, sufficientFunds: true, paymentAmount: 1000, riskScore: 5, vendorId: "vendor://payments", // must exactly equal `target` }, }); async function main() { try { const trustRecord = await client.execute(transaction); console.log("Business Transaction ID:", transaction.businessTransactionId); console.log("Trust Record ID: ", trustRecord.trustRecordId); console.log("Signature Algorithm: ", trustRecord.signature.algorithm); console.log(JSON.stringify(trustRecord, null, 2)); } catch (error) { if (error instanceof ExecutionRejectedError) { console.error("Rejected:", error.message); process.exitCode = 1; return; } throw error; } } main(); ``` Run it: ```bash npx tsx quickstart.ts ``` Captured on 2026-10-02 from a run against a local server (trimmed), using the same `createBusinessTransaction()` call this page documents (see `typescript/examples/06-create-business-transaction.ts` in this repository for the exact source, and `typescript/test/integration/examples.integration.test.ts` for the test that proves it stays runnable): ```json { "trustRecordId": "7966d3f2-758b-4fef-8ef4-119f7c759560", "businessTransactionId": "54958790-351c-4c0f-bf52-918676952b53", "transaction": { "businessTransactionId": "54958790-351c-4c0f-bf52-918676952b53", "authority": { "authorityId": "22d127fe-d55a-4188-ba16-469b1eb7688f", "authorityType": "SERVICE", "principalId": "alice@example.com", "displayName": "Alice", "issuedAt": "2026-10-02T11:16:44.451Z" }, "intent": { "action": "test:fixture-execute", "target": "vendor/vendor-123", "parameters": { "amount": 100, "currency": "USD" } }, "policy": { "name": "vendor-payment", "version": "2.1.0", "schemaVersion": "1.0.0", "contentHash": "18f4acca151fef1c45c867f622bdd4edd5d640aa38b95e026430aba10756ee2d", "governanceAnchor": { "status": "NO_APPROVAL_RECORD" } }, "status": "RECEIVED" } } ``` The full `ExecutionTrustRecord` also includes `executions[0].decision` (`outcome: "APPROVED"`), `executions[0].evidence` (what the connector actually did), and the `signature` block covered in [Verify independently](/guides/verify-independently). Field names are `camelCase` here; the same record from the Python SDK uses `snake_case` for the same fields (see [Python SDK Quickstart](/guides/python-sdk-quickstart)). ## Troubleshooting **`NetworkError: connect ECONNREFUSED`**: the Runtime isn't running, or you're pointed at the wrong `endpoint`. Confirm step 1's `curl http://localhost:3000/health` succeeds first. **`AuthenticationError` on every call**: either you omitted `apiKey` against a server that doesn't have `PARMANA_AUTH_DISABLED=true` set, or the key is wrong. The demo key above, `my-secret-api-key`, only works against a server started exactly as shown in step 1, with its `PARMANA_API_KEYS` entry. **`ExecutionRejectedError: Execution rejected: ...`**: the policy evaluated your signals and declined. The message names which rule failed; check [Write your first policy](/guides/write-your-first-policy) for how `vendor-payment@2.1.0` reads its signals. A message naming `humanApproved` means the approval is missing, expired, already used, for another target, below the amount, or from a key the server does not trust: sign a fresh one. **A `400` about a mismatched id**: you built the `BusinessTransaction` object by hand instead of through `createBusinessTransaction()`. See [`createBusinessTransaction()`, no more hand-synced ids](/sdks/typescript#createbusinesstransaction-no-more-hand-synced-ids) for exactly which three id pairs the server cross-checks. ## Next The concepts behind what you just ran: transactions, decisions, proofs. Wire this exact call into LangChain.js, the Vercel AI SDK, or a server endpoint. The same flow against a real connector and real infrastructure, not the local test fixture. Read back or re-verify the `ExecutionTrustRecord` you just created. The full error taxonomy, model types, and test suite. --- ## Python SDK Source: https://docs.parmanasystems.com/sdks/python **[AVAILABLE]**, **published**, [`parmana` on PyPI](https://pypi.org/project/parmana/). Real, public, installable with a plain `pip install parmana`. This page said `v1.0.5` and described only an editable local install until 2026-09-14; both were stale, confirmed against the live PyPI registry while fixing this page. 119 passing tests (1.3.0), 0 placeholders, `black`/`ruff`/`mypy --strict` all clean across the whole package. Bearer-key authentication, the error taxonomy below, and `PolicyApi.validate()` are all closed and verified against a real running local server. `test_quickstart_example.py` proves the documented quickstart script itself runs. v1.1.2 added `create_business_transaction()` (see below) and the `offline_verifier.py` fix. Two quick follow-up releases, v1.1.3 and v1.1.4, corrected the published package metadata (repo name, docs domain, added a `Website` URL); v1.1.5 and v1.1.6, both published 2026-09-20, follow; v1.6.0, published 2026-10-11, is the current version. It adds the business signal source and authority grant methods, and `conditions` on a release: see [New in 1.6.0](#new-in-160). v1.5.0, published 2026-10-04, added the external connector methods, `policy_in_effect()` and `verify_parmana_release()`, fixed offline verification of a decoded record, and moved to the Apache License 2.0: see [New in 1.5.0](#new-in-150). v1.4.0, published 2026-09-29, added `sign_approval()` and the approver methods: see [New in 1.4.0](#new-in-140). v1.3.0, published 2026-09-25, added policy governance, step up signing, `caller()`, `public_key()`, `trust_records.list()` and `latest_receipt()`, and lets the offline verifiers take the SDK's own models: see [New in 1.3.0](#new-in-130). v1.2.0, published 2026-09-21, added the Execution Intent methods and an offline intent verifier. v1.1.6 adds `server_code` to `InternalServerError`. v1.1.5 lets the offline verifier accept records over 4096 bytes that AWS KMS signed as a fixed size commitment, and declares its `cryptography` dependency as the optional `verify` extra (`pip install "parmana[verify]"`). v1.1.4 was published 2026-09-14. See [Changelog](/changelog) and the fix history at the bottom of this page. ## Install ```bash pip install parmana ``` The SDK is licensed under the Apache License 2.0. It is a client and needs a Parmana server, which is licensed separately. See [License and evaluation](/license). To use the standalone offline Trust Record verifier (`parmana.crypto`), install the optional `verify` extra, which adds the `cryptography` package it needs for Ed25519: ```bash pip install "parmana[verify]" ``` Real registry install, confirmed live against PyPI while fixing this page's own stale claim otherwise. To build from source inside this monorepo instead: ```bash pip install -e ./python ``` `py.typed` is shipped, this is a PEP 561 typed distribution, confirmed by installing into a clean venv and checking `parmana.__file__`'s directory for the marker. ## Bearer-key authentication `ParmanaClient(api_key=...)` is sent as `Authorization: Bearer ` on every request, set once on the underlying `requests.Session()`: ```python from parmana import ParmanaClient client = ParmanaClient( endpoint="http://localhost:3000", api_key="my-secret-api-key", ) health = client.execution.health() # GET /health, no key required ``` `api_key` is optional, for the same reason it's optional server-side: local development against a server started with `PARMANA_AUTH_DISABLED=true` needs no key. Every real deployment requires one. An omitted or wrong key gets a real `401`, raised as `AuthenticationError` (see below), not a silent failure. See [Authentication](/api-reference/authentication) for how keys are minted. ## Models are generated, not hand-maintained Every model in `python/parmana/models/*.py` is generated directly from the TypeScript AST of `packages/shared/src/domain/*.ts` (and `CryptoAlgorithms.ts`) by `python/scripts/generate_models.ts`, not hand-aligned copies. A drift guard (`npm run check:python-models`, wired into CI) regenerates into memory and fails the build if the committed output would change. Spot-checked field-for-field against the real JSON schemas (`Authority.display_name` optional, `Authorization.expires_at` optional, `BusinessTransactionMetadata` correctly optional-except-`business_transaction_id`, `ExecutionTrustRecord.settlement_confirmations` optional): accurate, no drift found. Enums are real Python `str, Enum` classes (e.g. `SignatureAlgorithm`, `VerificationStatus`), not bare strings. The auto-generated [Python SDK reference](/sdks/reference/python/client) (built from real docstrings, see below) deliberately excludes `python/parmana/models/*.py`: these dataclasses carry no per-field docstrings for a doc-generation tool to render, since they're generated code, not hand-written. For field-by-field shape, see the [TypeScript SDK reference's Interfaces](/sdks/reference/typescript/interfaces/BusinessTransaction), generated from the exact same source this page describes above, field for field identical modulo the `camelCase`/`snake_case` naming convention each language uses. ## Errors, correctly mapped to real conditions ```python from parmana import ConflictError, NotFoundError, ValidationError try: client.execution.execute(transaction) except ConflictError as exc: # HTTP 409 print(exc.status_code, exc) ``` | Status | Exception | | ---------------------------------------------------------------- | ------------------------ | | 400 | `ValidationError` | | 401 | `AuthenticationError` | | 403, code `POLICY_DENIED`, message starting `Execution rejected` | `ExecutionRejectedError` | | 403 (no `code`) | `AuthorizationError` | | 404 | `NotFoundError` | | 409 | `ConflictError` | | any other 5xx | `ServerError` | | connection failure | `NetworkError` | All inherit `ParmanaHttpError` → `ApiError`. Proven against the real `HttpTransport` (not a test double) using `responses`-mocked HTTP built from real, verified response shapes (`python/tests/test_http_transport.py`), and against an actual running server in `python/tests/test_live_server_integration.py`. **Two error-classification edge cases worth knowing about, both closed:** 1. `403` from the caller-principal-scoping check (`isPrincipalAllowed`, "Caller is not permitted to assert this authority.principalId.") is unrelated to policy rejection and maps to a dedicated `AuthorizationError`, not `ExecutionRejectedError`. 2. Policy rejection has its own dedicated `403` with `code: "POLICY_DENIED"` (see [Error catalog](/api-reference/error-catalog)), distinct from the caller-identity `403` above (no `code`). `build_http_error` checks for `code == "POLICY_DENIED"` ahead of the generic `403` branch, so the two never collide. A risk-rejected `test:fixture-execute` execution raises `ExecutionRejectedError` with `status_code == 403`. The client also reuses a `requests.Session()` (connection pooling) and retries idempotent GETs with backoff on 429/502/503/504, POSTs are never retried. ## Every product endpoint has a method ```python client.execution.execute(transaction) # POST /execute client.execution.health() # GET /health client.execution.version() # GET /version (the server's version) client.verification.verify(id) # POST /verify (fresh) client.verification.get_latest(id) # GET /verification/:id (cached) client.receipt.generate(id) # POST /receipt client.receipt.get_latest(id) # GET /receipt/latest/:id client.replay.replay(id) # POST /replay client.transactions.create(transaction) # POST /transactions client.transactions.get(id) # GET /transactions/:id client.transactions.list() # GET /transactions client.trust_records.get(id) # GET /trust-records/:id client.trust_records.list() # GET /trust-records (1.3.0) client.refusal.get(id) # GET /refusal/:id client.refusal.verify(record) # POST /refusal/verify client.audit.verify(event, signature) # POST /audit/verify client.execution_intents.get(id) # GET /execution-intents/:id client.execution_intents.verify(intent) # POST /execution-intents/verify client.execution_intents.list_unfinalized() # GET /execution-intents/unfinalized client.execution_intents.finalize(id) # POST /execution-intents/:id/finalize client.execution_intents.resolve(id, resolution=..., note=...) # POST /execution-intents/:id/resolve client.policy.validate(policy_id, policy_version) # POST /policies/validate client.policy.propose_change(name, version, proposed_content=..., reason=...) # (1.3.0) client.policy.list_changes(status) # GET /policies/pending-changes (1.3.0) client.policy.approve_change(id, step_up) # POST .../approve (1.3.0) client.policy.reject_change(id, reason, step_up) # POST .../reject (1.3.0) client.callers.me() # GET /callers/me (1.3.0) client.callers.public_key(key_id) # GET /keys/:keyId (1.3.0) ``` `client.version` (a property, not a call) is the **SDK's** version. The server's version is `client.execution.version()`. The routes that have no method, and why, are listed on [SDK coverage of the API](/sdks/api-coverage#operations-with-no-sdk-method-and-why): the readiness probe, the JWKS document, the API's own description files and the handbook download. That page also maps every method to its TypeScript equivalent. `client.transactions.create()` covers `POST /transactions`, a second independent entry point into the identical execution pipeline as `execute()`, differing only in its `201` status code. `policy.validate` takes `(policy_id, policy_version)`, not a policy document, matching what the route actually reads. **`POST /policies/validate` never uses the shared `{error, code?}` envelope at `400`/`404`.** Every status it returns (`200`, `400`, `404`) is `{valid, errors}`, the caller's answer, not an SDK-level failure, so `policy.validate()` returns `{"valid": False, "errors": [...]}` for an unknown policy rather than raising `NotFoundError`. `401` is not exempted: it's generated by caller-auth middleware before this route's own handler runs, using the shared envelope like every other route. See [Error handling](/api-reference/error-handling). ## `create_business_transaction()`, no more hand-synced ids Added 2026-09-14, ships in v1.1.2, published, available via a plain `pip install parmana`. A `BusinessTransaction` has three id pairs the server's own `BusinessTransactionValidator` cross-checks before policy ever runs: `metadata.business_transaction_id` must equal `business_transaction_id`, `authorization.authority_id` must equal `authority.authority_id`, `intent.authorization_id` must equal `authorization.authorization_id`. Every "X must match Y" `400` documented in [the end-to-end Paytm guide](/guides/end-to-end-paytm-flow) came from hand-building a request and getting one of those wrong. `create_business_transaction()` derives all three automatically: ```python from parmana import ParmanaClient, PolicyReference, create_business_transaction client = ParmanaClient(endpoint=endpoint) transaction = create_business_transaction( principal_id="alice@example.com", # must match your key's allowed_principal_ids purpose="Vendor payment approval", action="test:fixture-execute", # a test capability, registered only with NODE_ENV=test target="vendor/vendor-123", parameters={"amount": 100, "currency": "USD"}, policy=PolicyReference(name="vendor-payment", version="2.1.0", schema_version="1.0.0"), signals={ # The approver's signed approval for this target, up to this amount # (parmana.crypto.sign_approval). Without it the request is refused. "humanApproved": True, "approvalArtifact": approval, "vendorVerified": True, "invoiceVerified": True, "paymentApproved": True, "sufficientFunds": True, "paymentAmount": 100, "riskScore": 5, "vendorId": "vendor/vendor-123", # boundSignals: SignalIntentBinder rejects this # request unless this exactly equals `target` above }, ) trust_record = client.execution.execute(transaction) ``` `business_transaction_id` defaults to a fresh `uuid4()` if you don't supply one. A value you do supply is your own responsibility to keep unique per attempt, since it also doubles as the server's idempotency key. `authority_type` defaults to `AuthorityType.SERVICE` (never `"AGENT"`, which doesn't exist server-side). See `examples/builder/run.py` (the same flow as `examples/quickstart/run.py`, side by side, for direct comparison) and `tests/test_builders.py` (9 unit tests) / `tests/test_builder_example.py` (proves the generated ids round-trip correctly through a real running server, not just in isolation). ## New in 1.6.0 ### Register a business signal source Register the business system that owns a fact a policy needs, an ERP, an order system, a CRM, so Parmana asks it instead of trusting the agent. One person proposes, a different person approves with a step up authorization; both need a human key. ```python change = maker.business_signal_sources.propose_register( name="erp", endpoint_url="https://orders.example.com/parmana/signals", public_key_pem=Path("erp-answers.public.pem").read_text(), key_id="erp-2026-10", reason="Release goods only against invoices the ERP says are paid.", ) checker.approve_business_signal_source_change( change.change_id, sign_policy_change_step_up( pending_policy_change_id=change.change_id, action="approve", private_key_pem=Path("step-up.private.pem").read_text(), key_id="checker-step-up-1", ), ) checker.list_business_signal_sources() # [BusinessSignalSource(name="erp", status="active", ...)] ``` `business_signal_source_changes(status=None)` lists proposals and `reject_business_signal_source_change(change_id, reason, step_up)` rejects one. To end a registration, call `business_signal_sources.propose_revoke(name=..., reason=...)` and approve it the same way. The endpoint and the signed protocol are in [Connect a business signal source](/guides/connect-a-business-signal-source). ### Grant an agent authority Say which agent may have which action decided, within which limits, until when. A policy with `requireAuthorityGrant` decides a request only for a caller holding a grant in force. ```python change = maker.authority_grants.propose_grant( caller_id="warehouse-agent", capability="erp:release-goods", limits={"parameters.amount": {"max": 100000}, "parameters.currency": {"oneOf": ["INR"]}}, valid_until="2026-12-30T00:00:00.000Z", reason="The warehouse agent releases goods for paid invoices up to 100000 INR this quarter.", ) checker.approve_authority_grant_change(change.change_id, step_up) # signed as above checker.list_authority_grants() # [AuthorityGrant(caller_id="warehouse-agent", status="active", ...)] ``` `authority_grant_changes(status=None)` and `reject_authority_grant_change(change_id, reason, step_up)` work as for sources. To end a grant, call `authority_grants.propose_revoke(caller_id=..., capability=..., reason=...)`. No one grants authority to themselves. See [Grant an agent authority](/guides/grant-an-agent-authority). ### Conditions on a release A release to an external connector now carries `conditions`: the business facts the decision rests on, each with `signalKey`, `source`, `claim`, `subject`, `value` and `validUntil`, covered by the signature. After `verify_parmana_release`, read them from `result.release["conditions"]`. Act only if each still holds in your records; otherwise answer `success: false` with `conditionsNotMet: true`. `python/examples/13_external_connector_endpoint.py` takes a `conditions_hold` check. ## New in 1.5.0 ### A decoded record verifies offline (fixes a 1.4.0 bug) A model the SDK decoded keeps the server's own JSON, and encodes back to exactly that JSON. So `verify_execution_trust_record_offline(record, keys)` verifies the `ExecutionTrustRecord` returned by `client.execute()` or `client.trust_record()` directly, including records with `previousChainHash: null`, and `refusal_record` and Execution Intent verification send the server exactly what it signed. In 1.4.0 those records failed with a hash mismatch (see the warning under [Verify records offline](#verify-records-offline-straight-from-the-clients-results)). A model changed with `dataclasses.replace()` is encoded from its fields, so a changed record still fails. ### What a request must carry, from the server `client.policy_in_effect(capability)` (also `client.policy.in_effect(...)`) calls `GET /policies/in-effect` and returns a `PolicyInEffect`: the policy to declare and what the request must carry. Signal names keep their exact spelling; they are never converted to snake_case. ```python in_effect = client.policy_in_effect("paytm:refund") in_effect.policy.version # "1.2.0" in_effect.policy.schema_version # "1.0.0" in_effect.signals.facts # ["fraudCheckPassed", "managerApproved", "refundAmount", "refundEligible"] in_effect.signals.bound # {"refundAmount": "parameters.amount"} in_effect.signals.approval # {"managerApproved": {"resourceId": "parameters.orderId", "value": "parameters.amount"}} ``` Call it before every request and declare `name`, `version` and `schemaVersion` exactly as returned. It raises `AuthorizationError` (403, `CAPABILITY_NOT_ALLOWED`), `NotFoundError` (404, `CAPABILITY_NOT_BOUND`), `ConflictError` (409, `NO_APPROVED_POLICY_VERSION`) or `InternalServerError` (503, `POLICY_VERSION_UNAVAILABLE`); in each case send nothing. The step by step use is in [Integrate Parmana: specification for AI agents](/agents/integrate), step 3. ### Register an external connector Connect your own system with no Parmana code change: one person proposes registering a capability to your HTTPS endpoint, a different person approves it with a step up authorization. Both need a human key. ```python change = maker.external_connectors.propose_register( capability="erp:create-invoice", endpoint_url="https://erp.example.com/parmana/release", policy="erp-invoice", allowed_parameters=["amount", "currency"], reason="Finance creates invoices in the ERP through Parmana.", ) checker.approve_external_connector_change( change.change_id, sign_policy_change_step_up( pending_policy_change_id=change.change_id, action="approve", private_key_pem=Path("step-up.private.pem").read_text(), key_id="checker-step-up-1", ), ) checker.list_external_connectors() # [ExternalConnector(capability="erp:create-invoice", status="active", ...)] ``` `external_connector_changes(status=None)` lists proposals and `reject_external_connector_change(change_id, reason, step_up)` rejects one. To end a registration, call `external_connectors.propose_revoke(capability=..., reason=...)` and approve it the same way. The whole path, with the endpoint, is in [Connect any external system](/guides/connect-any-external-system). ## New in 1.4.0 Published to PyPI on 2026-09-29. `pip install "parmana[verify]"` installs it with `sign_approval()`. ### Sign an approval on the approver's machine `parmana.crypto.sign_approval()` makes the signed approval a policy with `approvalSignals` asks for, the same one `scripts/sign-approval.ts` makes. It needs `pip install "parmana[verify]"`. The private key never leaves the process. ```python from pathlib import Path from parmana.crypto import sign_approval approval = sign_approval( private_key_pem=Path("manager-priya__manager-priya-key-1.private.pem").read_text(), approver_id="manager-priya", key_id="manager-priya-key-1", capability="paytm:refund", resource_id="ORD-1042", max_amount=75000, # leave out when the policy declares no amount ) # The agent sends it in signals["approvalArtifact"] on a new request. ``` The approval is valid for 15 minutes by default (`ttl_seconds`, up to a day) and can be used once. See [Human approval](/concepts/human-approval). ### Manage approvers Add, rotate and revoke the keys trusted to sign approvals, through maker checker, with no deploy. Every call needs an API key added as a human. ```python from parmana.crypto import sign_policy_change_step_up # The proposer change = proposer.approvers.propose_add( approver_id="manager-priya", key_id="manager-priya-key-1", public_key_pem=public_key_pem, # the approver's .public.pem file reason="Priya approves refunds for the West region from October.", ) # A different person, the checker pending = checker.approver_changes("PENDING_APPROVAL") checker.approve_approver_change( change.change_id, sign_policy_change_step_up( pending_policy_change_id=change.change_id, action="approve", private_key_pem=checker_step_up_key_pem, key_id="checker-step-up-1", ), ) approvers = checker.list_approvers() ``` `reject_approver_change(change_id, reason, step_up)` rejects one, and `approvers.propose_revoke(...)` proposes a revocation. See [Manage approvers](/guides/manage-approvers). ## New in 1.3.0 Published to PyPI on 2026-09-25. `pip install parmana` installs it. On 1.2.0 and earlier none of this section exists. The full list of what each version covers is on [SDK coverage of the API](/sdks/api-coverage). 1.3.0 adds policy governance to both SDKs and makes the Python SDK cover the same operations as the TypeScript SDK. ### Who the key is, and the deployment's public key ```python from parmana import ParmanaClient client = ParmanaClient(endpoint="http://127.0.0.1:3000", api_key="pk_local_...") me = client.caller() # CallerIdentity(caller_id='local-operator', allowed_principal_ids=['local-operator'], # allowed_capabilities=['paytm:refund'], unrestricted_capabilities=False) key = client.public_key("default") # key.pem is the SPKI PEM that signs Trust Records, Refusal Records and Execution Intents ``` `public_key()` needs no API key. Fetch it once and keep it. ### Verify records offline, straight from the client's results ```python from parmana import ParmanaClient from parmana.crypto import verify_execution_intent_offline, verify_execution_trust_record_offline client = ParmanaClient(endpoint="http://127.0.0.1:3000", api_key="pk_local_...") pem = client.public_key("default").pem record = client.trust_record("5015499d-b7ba-48fe-a9ad-821862d70ad0") result = verify_execution_trust_record_offline(record, {"default": pem}) # OfflineVerificationResult(valid=True, hash_valid=True, legacy_signature_valid=True, ...) view = client.execution_intent("5015499d-b7ba-48fe-a9ad-821862d70ad0") verify_execution_intent_offline(view.intent, {"default": pem}) ``` From 1.3.0 the verifiers accept the SDK's own models, as above, as well as the raw JSON dict of a response or a file. Needs `pip install "parmana[verify]"`. Known issue in 1.4.0, fixed in [1.5.0](#new-in-150): a decoded `ExecutionTrustRecord` drops a `null` `previousChainHash`, so `verify_execution_trust_record_offline` reports a hash mismatch for a record that carries one, although the record is intact. Records from a Postgres backed server, such as the [public sandbox](/playground), carry it. With 1.4.0, verify the record as the server sent it, for example `requests.get(f" {url}/trust-records/ {business_transaction_id}", headers=headers).json()`, or a saved JSON file. The TypeScript SDK is not affected. ### Check a release at an external connector endpoint (1.5.0) An endpoint registered as an external connector receives each approved request as a signed release. It checks the release before it acts. Needs `pip install "parmana[verify]"`. ```python from parmana.crypto import verify_parmana_release result = verify_parmana_release( body, public_keys={"default": pem}, # client.public_key("default").pem audience="https://erp.example.com/parmana/release", # this endpoint, as registered is_already_executed=lambda business_transaction_id: business_transaction_id in answers, ) if not result.valid: ... # Act on nothing. result.errors says why. elif result.already_executed: ... # Answer with the first result again; do not act twice. else: ... # Act on result.release["capability"], ["target"] and ["parameters"] only. ``` The same checks, in the same order and with the same error texts, as the TypeScript SDK's `verifyParmanaRelease`. A whole endpoint built on it is `python/examples/13_external_connector_endpoint.py`. ### Approve a policy change The approver signs on their own machine; the private key never leaves it. Needs `pip install "parmana[verify]"`. ```python import json from pathlib import Path from parmana import ParmanaClient from parmana.crypto import sign_policy_change_step_up endpoint = "http://127.0.0.1:3000" proposer = ParmanaClient(endpoint=endpoint, api_key="pk_local_... (alice)") approver = ParmanaClient(endpoint=endpoint, api_key="pk_local_... (bob)") change = proposer.propose_policy_change( "customer-refund", "1.0.0", proposed_content=json.loads(Path("policies/customer-refund/1.2.0/policy.json").read_text()), reason="Adopt the shipped customer-refund policy.", ) open_changes = approver.policy_changes("PENDING_APPROVAL") # each entry has diff.current and diff.proposed step_up = sign_policy_change_step_up( pending_policy_change_id=change.pending_policy_change_id, action="approve", private_key_pem=Path("approver/step-up.private.pem").read_text(), key_id="bob", ) approved = approver.approve_policy_change(change.pending_policy_change_id, step_up) # approved.status is PendingPolicyChangeStatus.APPROVED ``` To reject, sign with `action="reject"` and call `reject_policy_change(change_id, "Why it is rejected.", step_up)`. A signed authorization is valid once, for one change and one action, for 120 seconds unless you pass `ttl_seconds`. `proposed_content` is sent exactly as given: keys are never rewritten, unlike the SDK's model encoding. The rules and every error are on [Approve a policy](/self-hosted/policy-approval). ### List Trust Records, and the latest receipt ```python from parmana import ParmanaClient client = ParmanaClient(endpoint="http://127.0.0.1:3000", api_key="pk_local_...") records = client.trust_records.list(page=1, page_size=25, since="2026-09-01T00:00:00Z") receipt = client.latest_receipt("5015499d-b7ba-48fe-a9ad-821862d70ad0") ``` `trust_records.list()` returns only records of transactions this key submitted, newest first. A page counts transactions, so it may hold fewer records than `page_size`. `latest_receipt()` reads the most recent receipt without generating a new one, unlike `receipt()`. ## Test suite 119 tests, `pytest`, confirmed against a real run on 2026-09-25 (1.3.0): unit tests for every error-mapping case including the two classification cases above, bearer-key header attachment tests, `create_business_transaction()` (9 cases, `tests/test_builders.py`), and three real-server integration suites. `test_live_server_integration.py` spawns the actual `@parmana/api` process (`npx tsx packages/api/src/server.ts`, the same entry point `npm run dev` runs) on a real OS-assigned TCP port and drives it with the real `ParmanaClient` over real HTTP: a real `401`, a real `403`, a real `400`, a real policy rejection, a real `404`, a real `409` duplicate, and the `policy.validate()` `404` case, each asserted against the exact typed result and exact message the server returned. `test_quickstart_example.py` does the same for `run_quickstart()`, the quickstart example script itself, additionally asserting its documented printed output still matches what it actually prints. `test_builder_example.py` does the same for `run_builder_example()` (see above), additionally asserting every builder-derived id pair round-trips correctly. `tests/test_sdk_alignment.py` covers everything added in 1.3.0, including a step up authorization signed in Python and accepted by the server's own TypeScript verifier. `ruff`/`black`/`mypy --strict` are clean across the whole package: `python -m ruff check .`, `python -m black --check .`, and `python -m mypy` all pass with zero findings. ## Next The full, no-ambiguity runbook this SDK builds toward: real infrastructure, every error message, every fix. The other maintained SDK: same bearer-key model, same error taxonomy shape. Every error the real API returns, independent of any SDK. Every class and method, generated from the real docstrings, in the sidebar under SDKs. --- ## Python SDK configuration and behavior Source: https://docs.parmanasystems.com/sdks/python-configuration This page states exactly how the `parmana` package behaves. If a guide and this page disagree, this page is right, and please report the guide. Every value here was read from the SDK source. ## Create a client ```python from parmana import ParmanaClient client = ParmanaClient( endpoint="https://your-parmana.example.com", api_key="your API key", ) ``` The client is synchronous only. There is no async variant. All arguments are keyword only. ## Configuration options | Argument | Type | Default | What it does | | ---------------- | -------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `endpoint` | str | None, required | The base URL of the Parmana server. A missing or empty value raises `ConfigurationError`. | | `api_key` | str or None | `None` | When set, every request carries `Authorization: Bearer `. When it is `None`, no `Authorization` header is sent and a server with authentication on answers `401`. | | `timeout` | int, seconds | `30` | The timeout applied to each request. A request that exceeds it raises `TimeoutError`. | | `max_retries` | int | `3` | The number of retries for a `GET` request. See the next section. | | `backoff_factor` | float, seconds | `0.5` | The exponential backoff factor between retries. | | `debug` | bool | `False` | Writes request and response debug logging to the `parmana` logger. | Every request also carries `Content-Type: application/json` and `Accept: application/json`. The client reuses one `requests.Session`, so connections are pooled. ## Retries Retries are **on by default for `GET` requests**, which is different from the TypeScript SDK, where they are off until you enable them. Set `max_retries=0` to turn them off. | Request | Retried? | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` requests | Yes, up to `max_retries` times, on a connection error or a `429`, `502`, `503` or `504` response. With the default of `3` that is up to 4 requests. When the server sends a `Retry-After` header on a `429`, the client honors it. After the last retry the response is turned into an error as described below. | | `POST` requests (`execute`, `create_transaction`, `verify`, `replay`, `receipt`, `validate_policy`, `verify_refusal_record`, `verify_execution_intent`, `finalize_execution_intent`, `resolve_execution_intent`, `verify_audit_event`, from 1.3.0 `propose_policy_change`, `approve_policy_change`, `reject_policy_change`, from 1.4.0 `approvers.propose_add`, `approvers.propose_revoke`, `approve_approver_change`, `reject_approver_change`, from 1.5.0 `external_connectors.propose_register`, `external_connectors.propose_revoke`, `approve_external_connector_change`, `reject_external_connector_change`, and from 1.6.0 `business_signal_sources.propose_register`, `business_signal_sources.propose_revoke`, `approve_business_signal_source_change`, `reject_business_signal_source_change`, `authority_grants.propose_grant`, `authority_grants.propose_revoke`, `approve_authority_grant_change`, `reject_authority_grant_change`) | **Never.** This is deliberate, because retrying `execute` could repeat a real action. | ## Errors Every non 2xx response, and every failure to get one, is raised as a typed error. All of them extend `ParmanaError`. | What happened | Error class | Notes | | --------------------------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------ | | HTTP `400` | `ValidationError` | The request is malformed. | | HTTP `401` | `AuthenticationError` | The key is missing or wrong. | | HTTP `403` with code `POLICY_DENIED` | `ExecutionRejectedError` | A policy refused. This is final. | | HTTP `403` with code `CAPABILITY_NOT_ALLOWED` | `AuthorizationError` | The server's code is on `server_code`. | | HTTP `403` with no code | `AuthorizationError` | The key may not assert that principal. | | HTTP `404` | `NotFoundError` | | | HTTP `409` | `ConflictError` | The `businessTransactionId` was already used. | | HTTP `429` | `RateLimitError` | `retry_after_seconds` carries the `Retry-After` value when present. | | Any `5xx`, including `500` and `503` | `InternalServerError` | `ServerError` is another name for the same class. `status_code` holds the status, `server_code` the server's code. | | Any other non 2xx status | `ParmanaHttpError` | The base class, with `status_code`. | | The connection failed | `NetworkError` | | | The request timed out | `TimeoutError` | A subclass of `NetworkError`. | | A missing or empty `endpoint` | `ConfigurationError` | Raised by the constructor. | Every HTTP error has `status_code`, `code` and `request_id`. **The `code` is the SDK's own code** (for example `SERVER_ERROR`), not the server's. The server's code is preserved on `server_code` for `CAPABILITY_NOT_ALLOWED` and, from 1.1.6, on any `InternalServerError`. That matters for the server errors that all arrive as `InternalServerError`: | Server code | Meaning | Do | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SIGNING_UNAVAILABLE` (503) | Refused before release. **Nothing was executed.** | Retry later with a new `businessTransactionId`. | | `EXECUTION_INTENT_UNAVAILABLE` (503) | Refused before release, because the signed [Execution Intent](/concepts/execution-intents) could not be stored. **Nothing was executed.** | Retry later with a new `businessTransactionId`. | | `CONNECTOR_NOT_REGISTERED` (503) | The action has no connector on this server. **Nothing was executed.** | Ask the operator to configure the connector, then retry with a new `businessTransactionId`. | | `EXECUTION_RECORD_INCOMPLETE` (500) | The action **was** released but its signed record could not be produced. | **Do not resubmit.** Tell the operator, who can rebuild the record with `POST /execution-intents//finalize`. The method is `finalize_execution_intent`, available from 1.2.0. | | `EXECUTION_OUTCOME_UNKNOWN` (502) | The action was released and the connector call failed, so it **may** have been performed. The server sends this since 2026-09-25; before, the same failure was a `500` with no code. | **Do not resubmit.** Tell the operator, who checks the target system and closes the Execution Intent with resolve. | From 1.1.6 read `error.server_code`. On 1.1.5 and earlier `server_code` is not set on `InternalServerError`, so use `status_code` (`503` or `500`) and the message: the `SIGNING_UNAVAILABLE` message ends with "Nothing was executed.", and the `EXECUTION_RECORD_INCOMPLETE` message says the action was released and names the `businessTransactionId` and the `authorizationId`. ## Methods The client has methods for the common calls, and a sub API for each area. | Client method | Request | Returns | | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | `execute(transaction)` | `POST /execute` | The signed Execution Trust Record. | | `create_transaction(transaction)` | `POST /transactions` | The same record, from the second entry point. | | `verify(business_transaction_id)` | `POST /verify` | A `Verification`. | | `get_latest_verification(business_transaction_id)` | `GET /verification/{id}` | The latest `Verification`. | | `transaction(business_transaction_id)` | `GET /transactions/{id}` | One `BusinessTransaction`. | | `trust_record(business_transaction_id)` | `GET /trust-records/{id}` | The Execution Trust Record. | | `policy_in_effect(capability)` (1.5.0) | `GET /policies/in-effect` | A `PolicyInEffect`: the policy to declare, its description, and `signals` (facts, schema, bound, approval). | | `validate_policy(policy_id, policy_version)` | `POST /policies/validate` | A dict with `valid` and `errors`. | | `propose_policy_change(name, version, proposed_content=..., reason=...)` (1.3.0) | `POST /policies/{name}/{version}/pending-changes` | A `ProposedPolicyChange`. The content is sent exactly as given. Needs a verified human credential. | | `policy_changes(status=None)` (1.3.0) | `GET /policies/pending-changes` | A list of `PolicyChangeForReview`, each with `diff.current` and `diff.proposed`. Needs a verified human credential. | | `approve_policy_change(pending_policy_change_id, step_up_authorization)` (1.3.0) | `POST /policies/pending-changes/{id}/approve` | The `PendingPolicyChange`, `APPROVED`. Needs a verified human with a step up key who is not the proposer. | | `reject_policy_change(pending_policy_change_id, rejection_reason, step_up_authorization)` (1.3.0) | `POST /policies/pending-changes/{id}/reject` | The `PendingPolicyChange`, `REJECTED`. Same caller rules. | | `list_approvers()` (1.4.0) | `GET /approval-issuers` | A list of `ApprovalIssuer`: keys in code and keys added through approver changes. Needs a verified human credential. | | `approver_changes(status=None)` (1.4.0) | `GET /approval-issuers/changes` | A list of `ApprovalIssuerChange`, newest first. | | `approve_approver_change(change_id, step_up_authorization)` (1.4.0) | `POST /approval-issuers/changes/{id}/approve` | The change, `APPROVED` and applied. Sign the step up with `change_id` as `pending_policy_change_id`. | | `reject_approver_change(change_id, rejection_reason, step_up_authorization)` (1.4.0) | `POST /approval-issuers/changes/{id}/reject` | The change, `REJECTED`. | | `list_external_connectors()` (1.5.0) | `GET /external-connectors` | A list of `ExternalConnector`, active and revoked. Needs a verified human credential. | | `external_connector_changes(status=None)` (1.5.0) | `GET /external-connectors/changes` | A list of `ExternalConnectorChange`, newest first. | | `approve_external_connector_change(change_id, step_up_authorization)` (1.5.0) | `POST /external-connectors/changes/{id}/approve` | The change, `APPROVED` and applied. Sign the step up with `change_id` as `pending_policy_change_id`. | | `reject_external_connector_change(change_id, rejection_reason, step_up_authorization)` (1.5.0) | `POST /external-connectors/changes/{id}/reject` | The change, `REJECTED`. | | `list_business_signal_sources()` (1.6.0) | `GET /business-signal-sources` | A list of `BusinessSignalSource`, active and revoked. Needs a verified human credential. | | `business_signal_source_changes(status=None)` (1.6.0) | `GET /business-signal-sources/changes` | A list of `BusinessSignalSourceChange`, newest first. | | `approve_business_signal_source_change(change_id, step_up_authorization)` (1.6.0) | `POST /business-signal-sources/changes/{id}/approve` | The change, `APPROVED` and applied. Sign the step up with `change_id` as `pending_policy_change_id`. | | `reject_business_signal_source_change(change_id, rejection_reason, step_up_authorization)` (1.6.0) | `POST /business-signal-sources/changes/{id}/reject` | The change, `REJECTED`. | | `list_authority_grants()` (1.6.0) | `GET /authority-grants` | A list of `AuthorityGrant`, active and revoked. Needs a verified human credential. | | `authority_grant_changes(status=None)` (1.6.0) | `GET /authority-grants/changes` | A list of `AuthorityGrantChange`, newest first. | | `approve_authority_grant_change(change_id, step_up_authorization)` (1.6.0) | `POST /authority-grants/changes/{id}/approve` | The change, `APPROVED` and applied. Sign the step up with `change_id` as `pending_policy_change_id`. | | `reject_authority_grant_change(change_id, rejection_reason, step_up_authorization)` (1.6.0) | `POST /authority-grants/changes/{id}/reject` | The change, `REJECTED`. | | `refusal_record(business_transaction_id)` | `GET /refusal/{id}` | A `RefusalRecord`. | | `verify_refusal_record(record)` | `POST /refusal/verify` | `True` or `False`. | | `execution_intent(business_transaction_id)` | `GET /execution-intents/{id}` | An `ExecutionIntentView`: the signed intent and its status. | | `verify_execution_intent(intent)` | `POST /execution-intents/verify` | `True` or `False`. No credential needed. | | `unfinalized_execution_intents(limit=None)` | `GET /execution-intents/unfinalized` | `UnfinalizedExecutionIntents`. Needs a verified human credential. | | `finalize_execution_intent(business_transaction_id)` | `POST /execution-intents/{id}/finalize` | `FinalizeExecutionIntentResult`. Never calls the connector. Needs a verified human credential. | | `resolve_execution_intent(business_transaction_id, resolution=..., note=...)` | `POST /execution-intents/{id}/resolve` | `ResolveExecutionIntentResult`. The note is required. Needs a verified human credential. | | `verify_audit_event(event, signature)` | `POST /audit/verify` | `True` or `False`. | | `health()` | `GET /health` | A dict with the health status. | | `latest_receipt(business_transaction_id)` (1.3.0) | `GET /receipt/latest/{id}` | The most recent `Receipt`, without generating a new one. | | `caller()` (1.3.0) | `GET /callers/me` | A `CallerIdentity`: caller ID, allowed principal IDs and capabilities. | | `public_key(key_id="default")` (1.3.0) | `GET /keys/{keyId}` | A `PublicKeyInfo` with the PEM, for the offline verifiers. No credential needed. | Two values are properties, not methods, so do not call them: `client.endpoint` is the configured endpoint, and `client.version` is the version of the **SDK package**, for example `1.3.0`. It is not the server's version. To read the server's version call `client.execution.version()`, which sends `GET /version`. The sub APIs cover the rest, for example `client.replay.replay(...)`, `client.receipt.generate(...)`, `client.receipt.get_latest(...)` and `client.transactions.list(...)`: | Sub API | Methods | | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `client.execution` | `health`, `version`, `execute` | | `client.verification` | `verify`, `get_latest` | | `client.replay` | `replay` | | `client.receipt` | `generate`, `get_latest` | | `client.transactions` | `create`, `get`, `list` | | `client.trust_records` | `get`, `list` (1.3.0) | | `client.policy` | `validate`, and from 1.3.0 `propose_change`, `list_changes`, `approve_change`, `reject_change`, and from 1.5.0 `in_effect` | | `client.refusal` | `verify`, `get` | | `client.execution_intents` | `verify`, `get`, `list_unfinalized`, `finalize`, `resolve` | | `client.audit` | `verify` | | `client.callers` | `me`, `public_key` (1.3.0) | | `client.approvers` | `list`, `propose_add`, `propose_revoke`, `list_changes`, `approve_change`, `reject_change` (1.4.0) | | `client.external_connectors` | `list`, `propose_register`, `propose_revoke`, `list_changes`, `approve_change`, `reject_change` (1.5.0) | | `client.business_signal_sources` | `list`, `propose_register`, `propose_revoke`, `list_changes`, `approve_change`, `reject_change` (1.6.0) | | `client.authority_grants` | `list`, `propose_grant`, `propose_revoke`, `list_changes`, `approve_change`, `reject_change` (1.6.0) | The full signature of every method is in the [API reference](/sdks/reference/python/client). ## Idempotency `businessTransactionId` is the idempotency key. Sending the same one twice returns `409` (`ConflictError`). The helper `create_business_transaction()` generates the identifiers and keeps the linked ones consistent, so use it instead of writing them by hand. The SDK never retries a `POST`, so a single call is a single attempt. ## Optional extra for offline verification `parmana.crypto` (the offline Trust Record verifier, and from 1.5.0 `verify_parmana_release(body, *, public_keys, audience, is_already_executed, now=None, clock_skew_seconds=30)` for an external connector endpoint to check a release from Parmana before it acts) needs the `cryptography` package. Install it with `pip install "parmana[verify]"`. A plain `pip install parmana` does not include it, and `import parmana` does not need it. ## Where to go next For a client wrapper with logging and a deployment checklist read [Python SDK production guide](/guides/python-sdk-production). For an agent that must fail closed read [Python SDK for AI agents](/guides/python-sdk-ai-agents). For the exact rules an agent follows read [Integrate Parmana: specification for AI agents](/agents/integrate). --- ## Python SDK Quickstart Source: https://docs.parmanasystems.com/guides/python-sdk-quickstart This walks through the same flow as the top-level [Quickstart](/quickstart), with more explanation at each step and a focus on the SDK's own ergonomics. If you just want the fastest path to a working local server, use [Quickstart](/quickstart) instead. This page assumes you're already inside a local clone of [the Parmana repo](https://github.com/pavancharak/parmana) with a server you can point at. **Want to see it work before you clone anything?** Point the SDK at the public sandbox: `ParmanaClient(endpoint="https://parmana-sandbox.vercel.app", api_key="2VfYWCzt_cBAPK-8uufX6ordfY2JuQhFPsohuEumKME", timeout=120)`. The [Playground](/playground) has a complete script for the published `parmana` package, run against the sandbox before it was published. ## Prerequisites - **Python 3.10 or later** (`python --version`) - **Node.js** (to run the Parmana server locally, `node --version`) - A local clone of the Parmana repo, set up as in [Quickstart](/quickstart): `.env` copied from `.env.example`, `npm ci` and `npm run build` run, and the `default` and `gateway` keys generated No AI agent action is authorized without a signed human approval, reads included ([Human approval](/concepts/human-approval)). In this walkthrough you also play the approver: you make an approver key, the local server trusts it, and you sign an approval before the agent's request. From the repo root: ```bash npx tsx scripts/generate-approver-key.ts \ --approver-id local-test-approver --key-id local-test-approver-key-1 \ --out-dir ~/.parmana ``` From the repo root, override storage to run with no external dependency, and trust the approver key (honored only with `NODE_ENV=test`): ```bash NODE_ENV=test \ PARMANA_STORAGE=memory \ PARMANA_API_KEYS='[{"callerId":"demo","keyHash":"325ededd6c3b9988f623c7f964abb9b016b76b0f8b3474df0f7d7c23b941381f","allowedCapabilities":["test:fixture-execute"]}]' \ PARMANA_TEST_APPROVER_PUBLIC_KEY_FILE=~/.parmana/local-test-approver__local-test-approver-key-1.public.pem \ npm run dev ``` Confirm it's up: ```bash curl http://localhost:3000/health # {"status":"UP"} ``` With `NODE_ENV=test`, a generic, credential-free test connector (`test-fixture`) registers automatically, no external credentials needed for this walkthrough. See [Quickstart](/quickstart) for what each flag does and how to generate a local Gateway keypair if you haven't already. ```bash pip install parmana ``` Real PyPI package. See [Python SDK](/sdks/python) for the current published version and what's inside it (generated models, a typed error taxonomy, a real bearer-key `requests.Session()`). ```python from parmana import ParmanaClient client = ParmanaClient(endpoint="http://localhost:3000", api_key="my-secret-api-key") ``` `PARMANA_API_KEYS` in the previous step holds one demo caller key for local development only: `callerId: "demo"`, raw key `my-secret-api-key`, allowed to use `test:fixture-execute` and to act as `demo`. Its raw key is public: never use it on a server anyone else can reach. `api_key` is optional only against a server started with `PARMANA_AUTH_DISABLED=true`. Every real deployment requires one, and an omitted/wrong key raises `AuthenticationError`, not a silent failure. The approver's step, on the approver's machine. It approves this action, this target, up to this amount, for 15 minutes, once: ```python from pathlib import Path from parmana.crypto import sign_approval approval = sign_approval( private_key_pem=Path.home().joinpath( ".parmana/local-test-approver__local-test-approver-key-1.private.pem" ).read_text(), approver_id="local-test-approver", key_id="local-test-approver-key-1", capability="test:fixture-execute", resource_id="vendor://payments", max_amount=1000, ) ``` `parmana.crypto` needs `pip install "parmana[verify]"`. `create_business_transaction()` derives every id pair the server checks for internal consistency (see [why that matters](/sdks/python#create_business_transaction-no-more-hand-synced-ids)), so there's nothing to hand-assemble and get wrong: ```python from parmana import PolicyReference, create_business_transaction transaction = create_business_transaction( principal_id="demo", purpose="Quickstart demo", action="test:fixture-execute", target="vendor://payments", parameters={"amount": 1000, "currency": "USD"}, policy=PolicyReference(name="vendor-payment", version="2.1.0", schema_version="1.0.0"), signals={ # The signed approval. Without it the request is refused. "humanApproved": True, "approvalArtifact": approval, "vendorVerified": True, "invoiceVerified": True, "paymentApproved": True, "sufficientFunds": True, "paymentAmount": 1000, "riskScore": 5, # vendor-payment@2.1.0 declares boundSignals: { "vendorId": "target" }. # This must exactly equal `target` above, checked before policy evaluation # ever runs, or the request is rejected as a binding-tamper attempt. "vendorId": "vendor://payments", }, ) print(transaction.business_transaction_id) ``` `test:fixture-execute` is a generic, credential-free capability built for exactly this kind of walkthrough. It's unrelated to any real connector, and only registers when the server runs with `NODE_ENV=test`. One call runs the policy decision and, if approved, the connector. There's no separate "authorize" step: ```python from parmana import ExecutionRejectedError try: trust_record = client.execution.execute(transaction) print(f"Approved: {trust_record.trust_record_hash}") except ExecutionRejectedError as exc: print(f"Denied: {exc}") ``` If the policy denies the transaction, `execute()` raises `ExecutionRejectedError` with the policy's own reason in the message. It never returns a "REJECTED" status you'd have to check for. `trust_record` is a real, signed `ExecutionTrustRecord`: ```python print(f"Trust Record ID: {trust_record.trust_record_id}") print(f"Trust Record Hash: {trust_record.trust_record_hash}") print(f"Signature Algorithm: {trust_record.signature.algorithm}") print(f"Decision: {trust_record.executions[0].decision.outcome}") print(f"Connector evidence: {trust_record.executions[0].evidence}") ``` This same call, run for real against a local server, is captured verbatim in [Quickstart's "Real output" step](/quickstart). Expect the same shape (a fresh hash each run, since the transaction and timestamp differ). See [Execution Trust Records](/concepts/execution-trust-records) for the complete field-by-field shape, and [Verify independently](/guides/verify-independently) to check the signature yourself instead of trusting this output. ## Full script ```python from pathlib import Path from parmana import ( ExecutionRejectedError, ParmanaClient, PolicyReference, create_business_transaction, ) from parmana.crypto import sign_approval client = ParmanaClient(endpoint="http://localhost:3000", api_key="my-secret-api-key") approval = sign_approval( private_key_pem=Path.home().joinpath( ".parmana/local-test-approver__local-test-approver-key-1.private.pem" ).read_text(), approver_id="local-test-approver", key_id="local-test-approver-key-1", capability="test:fixture-execute", resource_id="vendor://payments", max_amount=1000, ) transaction = create_business_transaction( principal_id="demo", purpose="Quickstart demo", action="test:fixture-execute", target="vendor://payments", parameters={"amount": 1000, "currency": "USD"}, policy=PolicyReference(name="vendor-payment", version="2.1.0", schema_version="1.0.0"), signals={ "humanApproved": True, "approvalArtifact": approval, "vendorVerified": True, "invoiceVerified": True, "paymentApproved": True, "sufficientFunds": True, "paymentAmount": 1000, "riskScore": 5, "vendorId": "vendor://payments", }, ) try: trust_record = client.execution.execute(transaction) print(f"Trust Record Hash: {trust_record.trust_record_hash}") print(f"Decision: {trust_record.executions[0].decision.outcome}") except ExecutionRejectedError as exc: print(f"Denied: {exc}") ``` The same, already-tested version of this script lives at `python/examples/quickstart/run.py`; `python/tests/test_quickstart_example.py` runs it against a real, freshly-spawned server on every test run, so it's verified to work, not just syntax-checked. ## Troubleshooting **`ConnectionError` / connection refused on `localhost:3000`** The server isn't running, or isn't listening on the port you're pointing at. Confirm with `curl http://localhost:3000/health`. **`ExecutionRejectedError` naming `humanApproved`** The approval is missing, expired, already used, for another target, below the amount, or signed by a key the server does not trust. Sign a fresh one, and check the server was started with `PARMANA_TEST_APPROVER_PUBLIC_KEY_FILE` pointing at the matching public key. **`AuthenticationError` (401)** Either no `api_key` was passed against a server that requires one, or the key is wrong. Confirm the demo key matches what's in the repo's committed `.env`, or that `PARMANA_AUTH_DISABLED=true` is actually set if you intended to skip auth. **`ExecutionRejectedError`** The named policy denied the transaction. The exception message is the policy's own reason. Check that every signal the policy references is present in `signals`, and that any `boundSignals` field (like `vendorId` above) exactly matches its bound intent field. **`ValidationError` (400), often "X must equal Y"** A hand-built transaction had two of the three cross-checked id pairs out of sync. Use `create_business_transaction()` instead of constructing the object by hand. It makes this class of mistake structurally impossible. ## Next The concepts behind what you just ran: transactions, decisions, proofs. Wire this into LangChain, CrewAI, FastAPI, and async agent code. Error handling, audit logging, and a deployment checklist. The same flow against a real connector and real infrastructure, not the local test fixture. --- ## Run the server from source Source: https://docs.parmanasystems.com/deployment/local **Running Parmana on your own infrastructure?** Use the [self hosted deployment](/self-hosted/overview): one Docker Compose command starts the server and its own Postgres, with keys, migrations and policies handled, and the [quickstart](/self-hosted/quickstart) takes you to a first signed decision. This page is for running the server directly from source, mainly for development. ## What runs today One Express process (`packages/api/src/server.ts`), single instance, no load balancer or orchestration assumed. Signing keys are local PEM files read by `FileKeyProvider`. Storage is either fully in-process (`memory`, non-persistent) or Postgres (`postgres`, or its older name `supabase`; both use `DATABASE_URL`). ## Configuration Real `.env` keys (from this repo, values redacted): ```bash NODE_ENV=... PORT=3000 PARMANA_STORAGE=postgres # or: supabase (same thing), memory HASH_PROVIDER=sha256 PRIMARY_SIGNATURE_PROVIDER=ed25519 # or: dilithium3 (needs Node >= 24) KEY_PROVIDER=local PARMANA_KEY_DIR=./keys PARMANA_GATEWAY_KEY_ID=gateway # optional, defaults to "gateway" TRUST_PROFILE=v1 LOG_LEVEL=... # read into config; gates @parmana/shared's createLogger/getLogger, not yet threaded through every console.* call site, see Roadmap PARMANA_POLICY_DIR=... HUBSPOT_PRIVATE_APP_TOKEN=... # credential for the hubspot connector, omit to leave it unregistered PARMANA_API_KEYS=... # required, fails closed if unset, see Authentication ``` `PARMANA_KEY_DIR` is a single directory containing every keypair `FileKeyProvider` reads: `default.*.pem` (authorization signing/verification) and `gateway.*.pem` (gateway attestation, see [Gateway attestation](/concepts/gateway-attestation)). They are deliberately separate keys in the same directory, not one key used for two purposes. `PARMANA_GATEWAY_KEY_ID` lets you point the gateway at a differently-named key pair (for example during rotation, run both `gateway` and `gateway-v2` side by side, then flip the env var) without touching `default.*.pem` at all. ## Running it ```bash NODE_ENV=test \ PARMANA_STORAGE=memory \ PARMANA_API_KEYS='[{"callerId":"demo","keyHash":"325ededd6c3b9988f623c7f964abb9b016b76b0f8b3474df0f7d7c23b941381f","allowedCapabilities":["test:fixture-execute"]}]' \ npm run dev ``` `NODE_ENV=test` matters even though `PARMANA_STORAGE=memory` is set: two other components, the Execution Gateway's replay-nonce store and the caller-authentication audit trail, default to Supabase-backed implementations outside `NODE_ENV=test` (`createNonceStore.ts`, `createCallerAuditSink.ts`) and will fail closed without a real, reachable Supabase project. `PARMANA_API_KEYS` above is a demo key for local development only, raw key `my-secret-api-key`, allowed to use `test:fixture-execute` and to act as `demo`. Its raw key is public: make real keys with `npm run generate:api-key`, see [Authentication](/api-reference/authentication). Generate the `default` and `gateway` keypairs first if you haven't, see [Quickstart](/quickstart) step 2. Without it, the server refuses to start: the gateway is wired unconditionally (see [The gateway](/concepts/the-gateway)), there is no code path that skips needing this key. ## What this deployment does not give you - **No self-service key management.** Caller authentication is real and fails closed (see [Authentication](/api-reference/authentication)), but issuing and rotating keys is an operator action (`scripts/generate-api-key.ts`), not an API call. - **No KMS in this mode.** With `KEY_PROVIDER=local` your private keys, both `default.private.pem` and `gateway.private.pem`, are files on disk, exactly the exposure that produced the incident noted on [Security](/security/overview). Set `KEY_PROVIDER=aws-kms` to keep the `default` key in AWS KMS, see [AWS KMS signing](/deployment/aws-kms-signing). - **Single process, single instance.** No HA, no load balancer assumed. Whether replicas share replay-nonce state depends on `NODE_ENV`: `MemoryNonceStore` (`NODE_ENV=test`, as above) is process-local, each process would accept the same authorization once, not once fleet-wide. Outside `NODE_ENV=test`, `SupabaseNonceStore` is durable and shared across replicas pointed at the same Supabase project, but this deployment shape is still one process behind no load balancer. - **No connectors registered unless their credentials are configured.** HubSpot, GitHub, Paytm, and Slack each register only when their own environment variable is set (`HUBSPOT_PRIVATE_APP_TOKEN` and similar). See [The gateway](/concepts/the-gateway) for what adding a new connector requires. ## Node version The Parmana server requires Node 24 or later. The root `package.json` sets `engines.node` to `>=24`, the Dockerfile builds from `node:24`, and CI runs Node 24. Use Node 24 or later everywhere, including for ML-DSA-65 (post quantum signing), which needs Node 24 or later for native `node:crypto` support. The TypeScript SDK (`@parmana/sdk`) is separate and only needs Node 20 or later. --- ## Production deployment runbook Source: https://docs.parmanasystems.com/deployment/production To run Parmana on your own infrastructure with one command, use the [self hosted deployment](/self-hosted/overview) instead. This runbook is for a hand built deployment, for example on Vercel with a Supabase database. This runbook takes you from an empty environment to a server that is running, authenticated, governed and verified. Do the steps in order. Every step ends with a **Check**. Do not go to the next step until the check passes. The exact meaning of every variable is in the [Environment variable reference](/deployment/environment-variables). If a startup error appears, look it up in the table at the bottom of that page. ## What you deploy One container image, one process. `docker/entrypoint.sh` starts `node packages/api/dist/server.js` and forwards `SIGTERM` and `SIGINT` to it. There is no second process. The same code also runs as a Vercel function, through `api/index.ts` and `vercel.json`. The steps below are the same on both. Section [Host specific steps](#host-specific-steps) lists the differences. ## Before you start You need: | You need | Why | | --------------------------------- | ---------------------------------------------------------------------------------------------------- | | A clone of the repository | The scripts below run from the repository root. | | Node and npm | The image uses Node 24. Run `npm install` once in the clone, then scripts run with `npx tsx`. | | A Postgres database | Production storage. A Supabase project works. Note its **transaction pooler** connection string. | | A host for the container | Fly.io, any Docker platform, or Vercel. | | Two people, each with a human key | One proposes policy changes (the maker) and a different one approves them (the checker). See step 7. | Production requires a real database, real signing keys and caller authentication. The server refuses to start without them, and it never falls back to something weaker. ## Step 1: Generate the signing keys Run from the repository root: ```bash npx tsx scripts/generate-keypair.ts --algorithm ed25519 --key-id default npm run generate:gateway-keys ``` Both write into `./keys` unless `PARMANA_KEY_DIR` is set. They refuse to overwrite existing files unless you add `--force`. **Check:** `keys/` contains `default.private.pem`, `default.public.pem`, `gateway.private.pem` and `gateway.public.pem`. Never commit the private files and never bake them into the image. The image starts with an empty `keys/` directory on purpose. To keep the signing key inside AWS KMS instead, set `KEY_PROVIDER=aws-kms` and read the signing keys section of the environment reference. The gateway key is still a file. ## Step 2: Create the database schema The server needs the tables in `supabase/migrations/`. Apply them with the tracked migration runner, from the repository root, with `DATABASE_URL` set to the production database. It applies each migration once, each in its own transaction, and records it in the table `parmana_schema_migrations`, so every later run applies only what is new. ```bash npm run db:migrate -- status # read only: lists applied and pending migrations npm run db:migrate -- apply --dry-run # read only: lists what apply would run npm run db:migrate -- apply # applies the pending migrations ``` The first line of every command names the database it is using and whether it writes, for example `Database: db.example.supabase.co:5432/postgres as postgres (writes)`. `DATABASE_URL` is taken from your shell, or from the repository's `.env` file when the shell does not set it: check that line before you run `apply`. **A database that already has the schema but no `parmana_schema_migrations` table** (it was set up with `scripts/apply-all-migrations.sql`, the Supabase SQL Editor or `supabase db push`) must be baselined once, so that `apply` does not run old migrations again: ```bash npm run db:migrate -- baseline --through ``` This records every migration up to and including that file as applied, without running any of them. Take the file name exactly as `status` lists it. Only baseline migrations the database really has. Do not run `scripts/apply-all-migrations.sql` on a database that holds data. It applies every migration again, several migrations drop and add back the same CHECK constraint, and a second run fails on rows written since (`docs/VERIFICATION-GAPS.md` G-61). It is only for a new, empty database, pasted into the Supabase SQL Editor. **Check:** `npm run db:migrate -- status` ends with `0 pending`, and in the SQL Editor `select to_regclass('consumed_nonces'), to_regclass('execution_audit_events'), to_regclass('policies'), to_regclass('execution_intents'), to_regclass('approval_issuers'), to_regclass('external_connectors');` returns six names and no empty value. **Upgrading a deployment that already exists.** Apply new migrations **before** you deploy the version that needs them: `npm run db:migrate -- apply`. Execution Intents are enforced by default and cannot be switched off in production, so a new version running without the `execution_intents` table refuses every execution with `503 EXECUTION_INTENT_UNAVAILABLE`. See [Execution Intents](/concepts/execution-intents#turn-it-on-the-deployment-order-matters). If a request later fails with `PGRST205` ("Could not find the table"), the schema cache is stale. Run `NOTIFY pgrst, 'reload schema';` in the SQL Editor, or use Dashboard, Database, API, Reload schema cache. ## Step 3: Create the caller keys Each caller gets its own key. Generate one for your first caller: ```bash npm run generate:api-key -- --caller-id my-agent --allowed-capabilities paytm:refund ``` The command prints the raw key **once**, and a JSON entry. Give the raw key to the caller and store it nowhere else. Keep the JSON entry for step 5. Make two human keys now, one for the maker and one for the checker, because step 7 needs both. **Every policy governance endpoint, including proposing a change, refuses a key that is not `credentialHolderType` `USER`.** A service caller such as `my-agent` cannot be the maker. ```bash npm run generate:api-key -- --caller-id my-maker --credential-holder-type USER npm run generate:api-key -- --caller-id my-checker --credential-holder-type USER --generate-step-up-key ``` The second command prints a bearer key, a step up private key, and a JSON entry. The checker keeps the bearer key and the private key. Only the JSON entries go to the server. **Check:** you hold three JSON entries (the caller, the maker and the checker). Combine them into one array for `PARMANA_API_KEYS`, for example `[{...},{...},{...}]`. Each entry has a 64 character lowercase `keyHash`. ## Step 4: Decide the connector settings A connector registers only when its settings are present. If you do not set a connector, the server still starts and every request for that connector's actions fails. For the refund connector set both of these, or neither: | Variable | Value | | ------------------------------- | -------------------------------------------------------- | | `PAYTM_CONNECTOR_URL` | The `https://` URL of your `parmana-paytm-agent` service | | `PAYTM_CONNECTOR_SHARED_SECRET` | The shared secret that service expects | The other connectors (HubSpot, Slack, GitHub) are in the [connector section of the environment reference](/deployment/environment-variables#connectors). To keep connector credentials in AWS Secrets Manager set `PARMANA_SECRETS_PROVIDER=aws-secrets-manager`. ## Step 5: Set the environment on your host Set these in your platform, not in a file in the image. The minimum for production is: | Variable | Value | | -------------------- | --------------------------------------------------------------------------------------- | | `NODE_ENV` | `production` | | `PARMANA_POLICY_DIR` | `./policies` (already set in the image) | | `PARMANA_API_KEYS` | The JSON array from step 3 | | `PARMANA_STORAGE` | `supabase` | | `DATABASE_URL` | Your Postgres connection string. Use the transaction pooler string on serverless hosts. | | `PARMANA_KEY_DIR` | `./keys`, with the four PEM files from step 1 mounted there | If your host cannot mount files, set `PARMANA_KEY_MATERIAL_JSON` instead. It is a JSON object of the form `{"default":{"privateKeyPem":"...","publicKeyPem":"..."},"gateway":{...}}`. It writes any key file that does not already exist into `PARMANA_KEY_DIR` at startup. Do not set `PARMANA_AUTH_DISABLED`. Do not set `NODE_ENV` to `test` or `development`. Do not set `SUPABASE_URL` or the other `SUPABASE_*` variables on the server, because it does not read them and only `DATABASE_URL` is used. **Check:** the list of variables on your host includes all six from the table, and none of the two forbidden ones. Optional: to notify approvers when a request waits for their approval, set `APPROVAL_EMAIL_TO` with the Resend integration, or `APPROVAL_WEBHOOK_URL` (https) and `APPROVAL_WEBHOOK_SECRET`, or both. See [Approval notifications](/guides/approval-notifications). ## Step 6: Deploy and confirm it started Build and run the image: ```bash docker build -t parmana-api . ``` Or use the image published with each GitHub release, `ghcr.io/pavancharak/parmana-api:`. Check it with `slsa-verifier` first and run it by digest, as shown in [Verifying the server image](/security/overview#verifying-the-server-image). Then start it on your host with the variables from step 5. The server validates every required setting before it opens the port. A wrong value makes it exit at once with a message that names the variable. **Check** (replace `$URL` with your server's base URL): ```bash curl -s "$URL/health" curl -s "$URL/ready" curl -s -o /dev/null -w "%{http_code}\n" -X POST "$URL/execute" -H "Content-Type: application/json" -d "{}" ``` The expected answers are: | Request | Expect | | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /health` | `200` with `"status":"UP"`. This checks that the process is alive. | | `GET /ready` | `200` with `"status":"READY"` and `"authDisabled":false`. It runs `SELECT 1` against the database, and checks that the `execution_intents` table exists. | | `POST /execute` with no key | `401` with a `WWW-Authenticate` header. This proves caller authentication is on. | Then read the startup log. The line `runtime_engine_constructed` must show `policyExecutionVerifierConfigured`, `signingReadinessConfigured` and `executionIntentsConfigured` all `true`: those are the policy approval check, the signing check before release, and the signed intent stored before release. They are on by default in production and cannot be switched off there. If `/ready` returns `503` with `NOT_READY`, read the `reason`. Either the database is unreachable (check `DATABASE_URL` and the network path), or the `execution_intents` table does not exist (apply `supabase/migrations/20260921120000_add_execution_intents.sql` from step 2). If `authDisabled` is `true`, remove `PARMANA_AUTH_DISABLED` and redeploy at once. ## Step 7: Load and approve your policies In production with Supabase storage, policies live in the `policies` table, not on disk. Do these two things. **7a. Copy the policies into the database, once.** From the repository root, with `DATABASE_URL` and `PARMANA_POLICY_DIR` set in your shell: ```bash npx tsx scripts/apply-policies-table-and-backfill.ts ``` It creates the `policies` table if needed and copies every policy found under `PARMANA_POLICY_DIR`. It prints one line per policy. **7b. Give every policy a signed approval.** In production the gateway refuses to release an action for a policy that has no signed approval record whose hash matches the live policy. This gate is always on and cannot be switched off by a variable. Approve each policy you will use with the maker checker flow: the maker proposes it with their own key, and a different person, the checker, reviews it and approves it on their own machine with their step up key: 1. The maker: `POST $URL/policies///pending-changes` with the policy file, unchanged, as `proposedContent`. 2. The checker: `npx tsx scripts/local-review-action.ts --pending-policy-change-id --action approve --private-key-file ./my-checker.step-up.private.pem --key-id my-checker --api-base "$URL"`, with their own key in `REVIEWER_KEY`. Pass `--api-base` every time. Its default is a fixed address that is not your server. `--key-id` is the checker's caller id from step 3, and the private key file is the one that step printed. Do not use `scripts/refresh-approved-policy-content.ts` here: it proposes and approves in one process, so one person holds both keys and the approval record names a second person who did not decide. The full procedure, and how to do it step by step by hand, is in [Policy governance: maker checker approval](/guides/policy-governance-maker-checker). Three rules matter here. The maker and the checker must be different callers. Both must have `credentialHolderType` set to `USER`. The checker also needs a step up public key. `scripts/backfill-legacy-policy-approvals.ts` can create approvals for every policy in one run under a fixed system identity. Those approvals are labeled as system approvals and do not record a human decision. Use it only if you accept that, and run it first without `--apply` to see what it would do. Never insert rows into `policy_change_approval_records` by hand: the table is the evidence of who approved what, and a hand written row cannot be told apart from a real approval afterwards. **Check:** ```bash export SUPABASE_URL="" export SUPABASE_ANON_KEY="" npx tsx scripts/verify-policy-changes-approved.ts --full-scan ``` This script is the one place that reads `SUPABASE_URL` and `SUPABASE_ANON_KEY`, and it reads them from your shell, not from the server. That is deliberate: it uses the low privilege `anon` role, which can only read approval records, so a CI credential cannot write. It needs a Supabase project, because it uses the REST layer. On a plain Postgres, read `policy_change_approval_records` with SQL instead. It is fail closed. It exits with a non zero code if it cannot complete the check or finds a live policy with no matching approval record, and it reports each one. ## Step 8: Run one real request Send one transaction that your policy approves, using the procedure in [Integrate Parmana: specification for AI agents](/agents/integrate) or one of the SDKs. Use a small, harmless action first. The action needs a configured connector (step 4). Without one, the request still passes the policy and the approval check, and then stops at release with `503 CONNECTOR_NOT_REGISTERED` and nothing executed. Configure the connector, then retry with a new `businessTransactionId`. **Check:** the response is the signed Execution Trust Record, and a repeat call with the same `businessTransactionId` returns `409`. Then verify the record independently with [Verify independently](/guides/verify-independently). A `403 POLICY_DENIED` means the policy refused, which is a correct answer. A `503 SIGNING_UNAVAILABLE` or `503 EXECUTION_INTENT_UNAVAILABLE` means nothing was executed and you can retry later with a new `businessTransactionId`. A `500 EXECUTION_RECORD_INCOMPLETE` means the action was released, so do not resubmit it. A signed intent exists for it: read it with `GET /execution-intents/`, and if its state is `RELEASED`, rebuild the record with `POST /execution-intents//finalize`. See [Execution Intents](/concepts/execution-intents) and [Troubleshooting](/troubleshooting). ## Step 9: Turn on monitoring Set up these before you send real traffic: | Monitor | Alert when | | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /ready` every 30 seconds | The status is not `200`, or `authDisabled` is `true`. | | Log event `rate_limit_store_not_durable` | It appears. It means rate limits are counted per instance, so `DATABASE_URL` is missing. | | Response codes `503` with `SIGNING_UNAVAILABLE` | Any occurrence. The signing path is unhealthy and nothing is being released. | | Response codes `503` with `EXECUTION_INTENT_UNAVAILABLE` | Any occurrence. The intent store or signing path is unhealthy and nothing is being released. | | Response codes `500` with `EXECUTION_RECORD_INCOMPLETE` | Any occurrence. An action was released with no signed record. Repair it with `POST /execution-intents//finalize`. | | Response codes `502` with `EXECUTION_OUTCOME_UNKNOWN` | Any occurrence. An action was released and its connector call failed; its outcome must be checked and the intent closed with resolve. Several in a row mean the connector is down. | | `GET /execution-intents/unfinalized` (human credential), for example every 15 minutes | Any entry in state `RELEASED`, which is a repairable released action with no signed record. Entries in `PREPARED` or `ERRORED` mean an action whose outcome must be checked at the connector, then closed with `POST /execution-intents//resolve`. A closed intent leaves the list. | | Log events `execution_intent_prepare_failed`, `execution_intent_mark_released_failed`, `execution_intent_mark_finalized_failed` | Any occurrence. All are logged at critical severity. | | Log event `approval_issuer_lookup_failed` | Any occurrence. The `approval_issuers` table could not be read, so approvals from approvers added through [Manage approvers](/guides/manage-approvers) are refused until it can. | | Log event `approval_issuer_change_approved` | Every occurrence, as an audit trail: an approver key was added or revoked, with who proposed and who approved it. | | Log event `approval_needed_notification_failed` | Repeated occurrences. The approval webhook is failing; refused requests are still in the Refusal Records. | | Log level | Set `LOG_LEVEL=info`. Use `debug` only while investigating. | ## Host specific steps ### Fly.io `fly.toml` sets the app name, the primary region and a health check on `GET /ready` every 30 seconds. 1. Generate the secrets file without printing it: `node scripts/generate-fly-secrets.mjs`. It writes `.flysecrets/secrets.env` with `PARMANA_KEY_MATERIAL_JSON` and `PARMANA_API_KEYS`, and leaves the database values for you to fill in. Add `DATABASE_URL`, `NODE_ENV` and `PARMANA_STORAGE` to it. 2. Apply them: `fly secrets import < .flysecrets/secrets.env`. 3. Deploy: `fly deploy`. 4. Confirm with `fly status`. Every machine should show a recent update and a passing check. Machine placement is Fly's choice, so confirm the region there and do not assume the configured one. Changing any secret restarts every machine. Treat the new value as live only after `fly status` shows passing checks. ### Vercel The function entry is `api/index.ts`, configured by `vercel.json`. The policy files ship inside the function through `includeFiles`. The filesystem is read only, which is why production policies live in the `policies` table. 1. Set the variables from step 5 in the Vercel project for the Production environment. Use the Supabase transaction pooler string for `DATABASE_URL`. 2. Deploy to production, then run the checks in step 6 against your production URL. 3. Changing an environment variable needs a new deployment before it takes effect. ### Any Docker platform Run the image with the variables from step 5, publish port `3000` (or set `PORT`), and mount the key files at `/app/keys` read only. Point the platform's health check at `GET /ready`. ## Shutdown and restarts On `SIGTERM` or `SIGINT` the server stops accepting connections and lets in flight requests finish, then exits. `SHUTDOWN_TIMEOUT_MS` (default `10000`) is the longest a stuck request can delay that. Rolling restarts are safe, because the replay nonce store and the audit trails are always in the database and not in process memory. ## Rotating secrets | To rotate | Do this | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | A caller key | Generate a new entry as in step 3, replace that caller's entry in `PARMANA_API_KEYS`, and redeploy. Give the caller the new raw key. The old key stops working when the new deployment is live. | | The verification key | Run `npx tsx scripts/rotate-verification-key.ts --algorithm ed25519`. It creates a new key under a fresh id and prints the variable to set. Never delete the old key files, because records already signed with them are verified by their stored key id. | | A connector secret | Change the variable on your host, or the secret in Secrets Manager, then redeploy. Values are read once at startup. | ## Next Every variable, its default and its effect. The same procedure as exact, checkable steps for an AI agent. --- ## AWS KMS signing Source: https://docs.parmanasystems.com/deployment/aws-kms-signing With `KEY_PROVIDER=aws-kms`, Parmana signs inside AWS KMS. The private key is created in KMS and never leaves it. The server sends KMS the bytes to sign and receives a signature back. This page takes you from no key to a verified deployment. Do the steps in order. Every step ends with a **Check**. ## What KMS signs, and what stays a file | Signed with | Where the key lives | | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- | | Execution Trust Records, Execution Intents, Refusal Records and audit event signatures | The KMS key named by `PARMANA_VERIFICATION_KEY_ID`, alias `default` unless you set it | | Execution authorizations, receipts, policy change approval records and the execution chain | Always the KMS key with the alias `default` | | The gateway attestation | Still a file, `gateway.private.pem`, in `PARMANA_KEY_DIR` or from `PARMANA_KEY_MATERIAL_JSON` | So a KMS deployment still needs the gateway key file. Only the `default` key moves to KMS. See the [Environment variable reference](/deployment/environment-variables#signing-keys-and-gateway-identity). Only `KEY_PROVIDER` values `local` and `aws-kms` work. `azure-key-vault`, `gcp-kms` and `hsm` pass validation but have no implementation, and the server refuses to start with them. ## Before you start | You need | Why | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | An AWS account and the AWS CLI | To create the key and the role. Sign in with a limited IAM user, not the account root user. See [Sign in with a limited user](#sign-in-with-a-limited-user). | | A Vercel project | The server reaches KMS from Vercel with short lived credentials (OIDC federation), so no AWS access key is stored anywhere. | | The repository cloned | The verification script (`npm run verify:kms`) runs from it. | ## Step 1: Create the key The key must be an asymmetric Ed25519 signing key. The server accepts no other key spec and stops with `KmsSigner only supports ECC_NIST_EDWARDS25519 keys` if it finds one. ```bash aws kms create-key \ --key-spec ECC_NIST_EDWARDS25519 \ --key-usage SIGN_VERIFY \ --description "Parmana signing key" \ --region ap-south-1 ``` Note the `KeyId` in the output. Then give the key the alias the server looks for. The server turns the key id `default` into `alias/default`: ```bash aws kms create-alias \ --alias-name alias/default \ --target-key-id \ --region ap-south-1 ``` If you manage AWS with infrastructure as code, the CloudFormation resource `AWS::KMS::Key` takes the same `KeySpec` and `KeyUsage` values, and `AWS::KMS::Alias` creates the alias. **Check:** ```bash aws kms describe-key --key-id alias/default --region ap-south-1 \ --query "KeyMetadata.[KeySpec,KeyUsage,KeyState]" --output text ``` You should see `ECC_NIST_EDWARDS25519 SIGN_VERIFY Enabled`. The default region for this project is `ap-south-1`. Use one region everywhere, because `AWS_REGION` in step 3 must match the key. ## Step 2: Create the role that Vercel assumes The server never holds an AWS access key. On Vercel it exchanges the project's OIDC token for short lived credentials by assuming an IAM role. Create a role with a trust policy and a permission policy. **Trust policy.** It lets your Vercel project, and only it, assume the role. This is the format from the [Vercel OIDC documentation for AWS](https://vercel.com/docs/oidc/aws). Replace the four values in square brackets: ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::[AWS ACCOUNT ID]:oidc-provider/oidc.vercel.com/[TEAM SLUG]" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "oidc.vercel.com/[TEAM SLUG]:sub": "owner:[TEAM SLUG]:project:[PROJECT NAME]:environment:production", "oidc.vercel.com/[TEAM SLUG]:aud": "https://vercel.com/[TEAM SLUG]" } } } ] } ``` The OIDC identity provider `oidc.vercel.com/[TEAM SLUG]` must exist in your AWS account first. Create it as the Vercel documentation describes. Give the role the production environment only. Do not add `preview`, because a preview deployment should not sign with the production key. **Permission policy.** Three actions on one key, and nothing else: ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["kms:Sign", "kms:GetPublicKey", "kms:DescribeKey"], "Resource": "arn:aws:kms:ap-south-1:[AWS ACCOUNT ID]:key/[KEY ID]" } ] } ``` | Action | Used for | | ------------------ | ------------------------------------------------------------------------------------------- | | `kms:Sign` | Signing, and the signing readiness probe that runs before an action is released | | `kms:GetPublicKey` | Publishing the public key at `/keys/default`, verifying signatures, and the readiness probe | | `kms:DescribeKey` | The startup check that the key exists, and reading its key spec | Note the role ARN, which looks like `arn:aws:iam::[AWS ACCOUNT ID]:role/[ROLE NAME]`. You need it in step 3. **Check:** in the IAM console, open the role and confirm the trust policy names your team and project, and the permission policy names your key. ## Step 3: Set the environment on Vercel Set these for the **Production** environment. The full meaning of each is in the [Environment variable reference](/deployment/environment-variables). | Variable | Value | | -------------- | --------------------------------------------------- | | `KEY_PROVIDER` | `aws-kms` | | `AWS_REGION` | `ap-south-1` (the region of the key) | | `AWS_ROLE_ARN` | The role ARN from step 2. It is a role, not a user. | Keep the gateway key set as before, either files in `PARMANA_KEY_DIR` or `PARMANA_KEY_MATERIAL_JSON`. Do not set an AWS access key. The server never reads one. Turn on OIDC federation for the Vercel project, as the [Vercel documentation](https://vercel.com/docs/oidc/aws) describes, so the function receives an OIDC token. Then deploy again, because variables are read once at startup. **Check:** `vercel env ls production` lists all three variables. ## Step 4: Deploy and check the key is reachable ```bash vercel deploy --prod ``` Then, with your base URL in `URL`: ```bash curl -s "$URL/keys/default" curl -s "$URL/ready" ``` The first call returns the public half of the KMS key: ```json { "keyId": "default", "algorithm": "ed25519", "use": "sig", "pem": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n" } ``` `/ready` should answer `{"status":"READY","authDisabled":false}`. On Vercel the app is built on the first real request, not when the module loads, because the OIDC token only exists while a request is being handled. The first request after a cold start therefore does the setup work, including the call to KMS. **Check:** `/keys/default` returns `"algorithm": "ed25519"`. A `500` here means the server could not reach the key. See [Troubleshooting](#troubleshooting). ## Step 5: Verify signing The repository has a script that signs test data through the same `KmsSigner` the server uses, on both sides of the 4096 byte limit. It changes nothing in AWS. It makes about seven `kms:Sign` calls, one `kms:GetPublicKey` and one `kms:DescribeKey` per run. Sign in with a limited user first (see [Sign in with a limited user](#sign-in-with-a-limited-user)), then run from the repository root: ```bash AWS_REGION=ap-south-1 AWS_PROFILE= npm run verify:kms ``` On PowerShell: ```powershell $env:AWS_REGION = "ap-south-1"; $env:AWS_PROFILE = ""; npm run verify:kms ``` Expected output: ```text PASS Key is an Ed25519 signing key (algorithm ed25519) PASS Signs 300 bytes as they are (commitment false, 300 bytes signed, verifies true) PASS Signs 4096 bytes as they are (the limit) (commitment false, 4096 bytes signed, verifies true) PASS Signs 5000 bytes as a 97 byte commitment (commitment true, 97 bytes signed, verifies true) PASS Signs 60000 bytes as a 97 byte commitment (commitment true, 97 bytes signed, verifies true) PASS A signature does not verify over different data (verifies false) PASS KMS refuses 5000 raw bytes (control) (ValidationException: ... length less than or equal to 4096) All 7 checks passed. ``` The last check is a control. It sends 5000 raw bytes straight to KMS and requires KMS to refuse them. It proves the limit is real and that the commitment is what makes larger messages work. The exit code is `0` when every check passes. If `AWS_ROLE_ARN` is set in your shell, the script ignores it and says so, because that variable selects the Vercel OIDC token, which only exists on Vercel. **Check:** the last line reads `All 7 checks passed.` ## Step 6: Verify a real record offline Signing that verifies in a script is not the same as a record that a third party can check. Run one real request as in [Integrate Parmana: specification for AI agents](/agents/integrate). Save the Execution Trust Record it returns, and get the key from the server: ```bash curl -s "$URL/keys/default" > key.json ``` Write the `pem` value into `default.public.pem`, then verify with only that public key: ```bash npx tsx scripts/verify-trust-record.ts record.json default=default.public.pem ``` **Check:** the output has `"valid": true` and `"errors": []`. Verifying the same record against a different public key must fail. A full record is usually larger than 4096 bytes, so this also proves the commitment path end to end. The rule a verifier you write yourself must follow is in [Verify independently](/guides/verify-independently). ## How messages over 4096 bytes are signed KMS refuses a raw Ed25519 message longer than 4096 bytes. A full Execution Trust Record is larger than that. Parmana signs a message over the limit as a fixed 97 byte commitment instead: the bytes `PARMANA-ED25519-LARGE-MESSAGE-V1`, one NUL byte, then the SHA-512 digest of the message. The result is still an ordinary Ed25519 signature. Messages of 4096 bytes or fewer are signed unchanged. No marker is stored on the record, because the choice depends only on the message length. The decision is recorded in ADR-0010 in `docs/adr`. ## Sign in with a limited user Do not run checks as the AWS account root user. Use an IAM user that can do only what this page needs. A user with the three actions from step 2, on the one key, is enough to run `npm run verify:kms`. It does not need `iam:*`, and it should have no access keys. Sign in with the console based login, which stores short lived credentials on your machine and needs no access key: ```bash aws login --profile ``` At the browser page choose **IAM user**, and enter the account ID, the user name and the password. Add the AWS managed policy `SignInLocalDevelopmentAccess` to the user, because `aws login` needs it. **Check:** ```bash aws sts get-caller-identity --profile ``` The `Arn` should end in `user/` and must not end in `:root`. Then confirm the user is limited: `aws iam list-users --profile ` should fail with `AccessDenied`. A second `aws login` with the same `--profile` replaces the credentials stored for that profile. If you use one profile for both root and a limited user, the last login wins. Give each its own profile name. ## Run the whole server against KMS locally To reproduce a full request with KMS signing on your machine, start the server with these settings, on top of the minimum from the [production runbook](/deployment/production): | Variable | Value | | -------------- | ------------------------------------------------------------------------------------- | | `KEY_PROVIDER` | `aws-kms` | | `AWS_REGION` | `ap-south-1` | | `AWS_PROFILE` | The limited profile from the previous section | | `AWS_ROLE_ARN` | **Not set.** A set value selects the Vercel OIDC token, which does not exist locally. | The gateway key is still read from `PARMANA_KEY_DIR`. A connector that verifies the gateway's authorization, such as the refund service, must use the same key provider and the same KMS access, or it will report an invalid signature. ## Troubleshooting | You see | Cause | Fix | | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `KEY_PROVIDER=aws-kms is configured, but no KMS key named "default" is reachable` | Wrong region, missing alias, or the role cannot describe the key | Check `AWS_REGION`, that `alias/default` exists in that region, and that the role has `kms:DescribeKey` on the key. | | `KmsSigner only supports ECC_NIST_EDWARDS25519 keys` | The key has a different key spec | Create the key as in step 1. A key's spec cannot be changed. | | `503` with code `SIGNING_UNAVAILABLE` and `AccessDeniedException` in the message | The role lacks `kms:Sign` or `kms:GetPublicKey`, or the key is disabled | Fix the permission or enable the key, then retry with a new `businessTransactionId`. Nothing was executed. See [Troubleshooting](/troubleshooting). | | `VercelOidcTokenError: The 'x-vercel-oidc-token' header is missing` | Something called AWS while the module loaded, before any request | Use the current `api/index.ts`, which builds the app on the first request. | | `ValidationException ... length less than or equal to 4096` | A build without the large message rule signed a message over the limit | Deploy a build that includes ADR-0010, then run `npm run verify:kms`. | | `503` with code `EXECUTION_INTENT_UNAVAILABLE` | The signed [Execution Intent](/concepts/execution-intents) could not be signed and stored before release. Under KMS the usual causes are the same as `SIGNING_UNAVAILABLE`, or a missing `execution_intents` table | Fix the permission or apply `supabase/migrations/20260921120000_add_execution_intents.sql`, then retry with a new `businessTransactionId`. Nothing was executed. | | `500` with code `EXECUTION_RECORD_INCOMPLETE` | The action was released and the signed record could not be produced afterwards | Do not retry as a new transaction. A signed Execution Intent exists, and an operator can rebuild the record without repeating the action. See [Execution Intents](/concepts/execution-intents) and [Troubleshooting](/troubleshooting). | | `npm run verify:kms` says the session expired | The local AWS login has expired | Run `aws login --profile ` again. | | A record fails offline verification against your local key | It was signed by KMS, and your local `default.public.pem` is a different key | Verify against the public key from `GET /keys/default`. | | `AccessDenied` for `iam:` actions when you use the limited user | This is correct. The limited user has no such permission | Nothing to fix. | | `Gateway private key not found` or `Gateway public key not found` | The gateway attestation key is still a file, and KMS does not supply it | Provide `gateway.private.pem` and `gateway.public.pem` in `PARMANA_KEY_DIR` or through `PARMANA_KEY_MATERIAL_JSON`. | | The gateway rejects a real execution with `failed checks [signatureVerified, ...]` | Signing and verification are using different keys | Check that the build includes the 2026-09-16 fix in `packages/api/src/bootstrap/createExecutionGateway.ts`, where the gateway verifies with the same KMS backed key provider the runtime signs with. | | A connector service reports only a generic HTTP 500 | The real reason is recorded on the other side | Query `execution_audit_events` by `business_transaction_id`. Both Parmana and `parmana-paytm-agent` write a `reason` there, see the [Database Schema Reference](/storage/schema-reference). | ## Keep in mind - Do not store a static AWS access key on Vercel for runtime signing. The OIDC role in step 2 exists so that no such key is needed. - The code resolves the logical key id `default` to `alias/default`. A descriptive alias on the same key does not replace it. - Keep `PARMANA_KEY_MATERIAL_JSON` (or the key files) after moving to KMS: it still carries the gateway key. - A per tenant key `tenant.` resolves to `alias/tenant/`: KMS alias names cannot contain a dot, so each dot in a key id becomes a slash. To give a tenant its own key, create a separate KMS key with that alias, for example `alias/tenant/acme` for `tenant.acme`. Without the alias, that tenant's authorizations are signed with `default`. This mapping is covered by unit tests only; it has not been run against real AWS. - Public keys and `DescribeKey` answers are cached in memory for 5 minutes per process. A failed or missing lookup is not cached. After repointing an alias to a new key, allow 5 minutes before records signed by the new key verify on every instance. - A keyId is always a logical name such as `default`, or an `alias/` name. A full key ARN is refused, including one named by a record being verified. - Keep `CRYPTO_MODE=single` (the default). Hybrid signing (Ed25519 and ML-DSA-65) uses local key files for both signatures, so the server refuses to start with `CRYPTO_MODE=hybrid` and `KEY_PROVIDER=aws-kms`. - After the key exists, narrow the administrator's own policy. `kms:CreateKey` with no resource limit is more than day to day work needs. ## What has been verified Verified on 2026-09-20, against a real KMS key (`alias/default`, `ap-south-1`, `ECC_NIST_EDWARDS25519`, enabled), using a limited IAM user with only the three actions above: | Check | Result | | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | | `npm run verify:kms`: messages of 300, 4096, 5000 and 60000 bytes signed and verified, a signature over other data rejected, and 5000 raw bytes refused by KMS | 7 of 7 pass | | The full server with `KEY_PROVIDER=aws-kms` signed an Execution Trust Record of about 5.6 KB (over the limit that failed in production on 2026-09-20), and it verified offline against the KMS public key only | Pass | | The same record verified against a different key | Failed, as it should | | Signing errors in the server log during the full run | None | Verified again on 2026-09-21 for [Execution Intents](/concepts/execution-intents), with the same key and the same limited IAM user, through the production Docker image, a real Postgres and the real `parmana-paytm-agent` using fake Paytm staging credentials: | Check | Result | | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | The full server with `KEY_PROVIDER=aws-kms` signed an Execution Intent before release, and signed the Execution Trust Record and the policy approval record | Pass | | The intent, and the Trust Record, each verified offline against the KMS public key only | Pass, 23 of 23 checks in the first run and 37 of 37 in the final run, which added closing an intent by hand | | The extra `kms:Sign` for the intent | About 35 ms median (34 to 40 ms, 10 samples), from a Windows machine to `ap-south-1`. **Not measured from Vercel.** | Each released action now costs **two** `kms:Sign` calls in the request, one for the intent and one for the Trust Record, plus the readiness probe at most once a minute per instance. Not covered by those runs: 1. **The Vercel OIDC role.** The runs used the local AWS credential chain. ADR-0010 records the live production verification of the OIDC role for the Trust Record, and no run has exercised the OIDC role for the intent signature, although it uses the same signer and the same permissions. 2. **A real Paytm refund.** The agent chain ran with fake Paytm staging credentials, on purpose, so Paytm rejected the call. No refund was sent. 3. **Key rotation and other regions.** Neither was tested. ## Next The full procedure to deploy and check a server. Check a signed record without trusting the server, including the large message rule. --- ## Environment variable reference Source: https://docs.parmanasystems.com/deployment/environment-variables This page lists **every** environment variable the Parmana server reads. A test in the repository fails if the code starts reading a variable that is not documented here, so this list cannot silently fall behind. ## How configuration is loaded 1. The server reads the process environment. It also loads a `.env` file, found by walking up from the package directory, but a variable already set in the process environment always wins over the file. 2. In production, set variables in your platform (Vercel, Fly.io, your container runtime) and do not ship a `.env` file. 3. Values are read once at startup. Change a variable, then restart or redeploy. 4. A wrong value fails **at startup** with a message that names the variable. It does not fail later under traffic. In the **Required** column below, "Always" means the server will not start without it. "Not test" means it is required whenever `NODE_ENV` is anything other than `test`, which includes production. "If" means it is required only under the stated condition. "No" means optional. ## The minimum for a production start These are the variables you must set for a production server that uses local signing keys and Supabase storage. Everything else has a safe default. | Variable | Example value | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `NODE_ENV` | `production` | | `PARMANA_POLICY_DIR` | `./policies` | | `PARMANA_API_KEYS` | `[{"callerId":"my-agent","keyHash":"<64 hex characters>","allowedCapabilities":["paytm:refund"]}]` | | `PARMANA_STORAGE` | `supabase` | | `DATABASE_URL` | `postgresql://postgres.:@:6543/postgres` | | `PARMANA_KEY_DIR` | `./keys` (with `default.private.pem`, `default.public.pem`, `gateway.private.pem` and `gateway.public.pem` inside), or use `PARMANA_KEY_MATERIAL_JSON` | Follow [Production deployment](/deployment/production) for the full procedure. ## Core | Variable | Required | Default | Values or format | What it does | | --------------------- | ----------------- | ------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `NODE_ENV` | Set it explicitly | `development` | `production`, `development`, `test`, or any string | Selects behavior. `test` turns off durable stores, key checks and connector requirements, and must never be used in production. `development` and `test` relax three safety gates, see `POLICY_EXECUTION_VERIFICATION_ENFORCED`, `SIGNING_READINESS_CHECK` and `EXECUTION_INTENTS_CHECK`. Any other value, or leaving it unset, keeps all three gates on. | | `PORT` | No | `3000` | Integer | TCP port the API listens on. | | `HOST` | No | `0.0.0.0` | Host name or IP address | Interface the API binds to. | | `SHUTDOWN_TIMEOUT_MS` | No | `10000` | Milliseconds | On `SIGTERM` or `SIGINT` the server stops accepting connections and drains in flight requests. If they have not finished within this time it exits anyway. | | `LOG_LEVEL` | No | `info` | `debug`, `info`, `warn`, `error` | Minimum level written to the structured logs. An unrecognized value falls back to `info`. | ## Caller authentication | Variable | Required | Default | Values or format | What it does | | ----------------------- | ------------------------------------------------ | ------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PARMANA_API_KEYS` | Always, unless `PARMANA_AUTH_DISABLED` is `true` | Empty | JSON array, see below | The callers allowed to use the API. The server refuses to start if it is empty or unset. | | `PARMANA_AUTH_DISABLED` | No | Off | Only the exact string `true` turns it on | Accepts every request with no caller authentication. For local development only. Never set it in a real deployment. `GET /ready` reports `authDisabled` so monitoring can alert on it. | ### The format of `PARMANA_API_KEYS` A JSON array. Each entry is an object: | Field | Required | Meaning | | ---------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `callerId` | Yes | A non empty string that names the caller. | | `keyHash` | Yes | The SHA 256 hash of the raw key, as 64 lowercase hexadecimal characters. Only the hash is ever stored. | | `allowedCapabilities` | No | The actions (`intent.action`) this key may invoke, for example `["paytm:refund"]`. If unset or empty the key may invoke **nothing**. The single value `"*"` allows every capability. | | `allowedPrincipalIds` | No | The `authority.principalId` values this key may assert. If unset the key may only assert its own `callerId`. | | `allowedTenantIds` | No | The `metadata.tenantId` values this key may send. The tenant id picks the signing key `tenant.`, so it must come from the key. If unset or empty, a request with a `tenantId` is refused with `403 TENANT_NOT_ALLOWED`; a request without one is unaffected. | | `credentialHolderType` | No | `USER`, `ROLE`, `SERVICE` or `ORGANIZATION`. Only `USER` keys may use the policy governance endpoints. If unset the key is treated as not human. | | `stepUpPublicKey` | No | A PEM encoded Ed25519 public key (SPKI). Required for a `USER` key that approves or rejects policy changes. | Generate an entry and its raw key with: ```bash npm run generate:api-key -- --caller-id my-agent --allowed-capabilities paytm:refund ``` The command prints the raw key **once**, and the JSON entry to put in `PARMANA_API_KEYS`. Give the raw key to the caller and never store it anywhere else. To make a human key add `--credential-holder-type USER`, and add `--generate-step-up-key` to also generate the step up signing key. If the value is not valid JSON, is not an array, or an entry is malformed, the server stops at startup with a message that names `PARMANA_API_KEYS`. ## Policies | Variable | Required | Default | Values or format | What it does | | ----------------------------------------------- | -------- | -------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PARMANA_POLICY_DIR` | Always | None | A directory path | The directory that holds `//policy.json` files. A relative path resolves from the process working directory, so start the server from the directory that contains it. | | `POLICY_EXECUTION_VERIFICATION_ENFORCED` | No | Enforced | `true` only has an effect, and only in `test` and `development` | Requires every policy to have a signed approval record whose hash matches the live policy before it can authorize or release anything. In production, or with `NODE_ENV` unset or any value other than `test` or `development`, it is always on and this variable is ignored. | | `POLICY_GOVERNANCE_INTEGRITY_CHECK_INTERVAL_MS` | No | `300000` (5 minutes) | Milliseconds | How often the server re checks that live policy files still match their approval records. A value of zero or less turns the periodic check off and leaves only the startup check. A value that is not a number uses the default. | ## Storage | Variable | Required | Default | Values or format | What it does | | ------------------- | ------------ | -------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `PARMANA_STORAGE` | No | `memory` | `memory`, `postgres`, `supabase` | Where business transactions and trust records are kept. `memory` loses everything on restart and must not be used in production. `postgres` uses any Postgres through `DATABASE_URL`, and is the name for a self hosted database. `supabase` is the same implementation under its older name, kept so existing deployments are unchanged. | | `DATABASE_URL` | Not test | None | A Postgres connection string | A direct Postgres connection string. It is required whenever `NODE_ENV` is not `test`, **regardless of `PARMANA_STORAGE`**, because the nonce store, the caller audit trail, the execution audit trail, the approval nonce store and the step up nonce store are always durable outside tests. It also backs shared rate limit counters. Use the Supabase **transaction pooler** string for serverless hosts, see [Production deployment](/deployment/production). | | `DATABASE_PROVIDER` | Never set it | None | None | Removed. If it is set at all, the server stops with "DATABASE_PROVIDER is no longer read; set PARMANA_STORAGE instead." | ## Signing keys and gateway identity | Variable | Required | Default | Values or format | What it does | | --------------------------------------- | ------------------------------------------------------------------------------------- | ------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PARMANA_KEY_DIR` | Not test | None | A directory path | The directory holding key files named `.private.pem` and `.public.pem`. The server needs `default` (signs trust records, receipts and authorizations) and the gateway key (`gateway` unless you changed `PARMANA_GATEWAY_KEY_ID`). With `KEY_PROVIDER=aws-kms` the `default` key comes from KMS instead, but the gateway key is still read from this directory. | | `PARMANA_KEY_MATERIAL_JSON` | No | None | `{"":{"privateKeyPem":"...","publicKeyPem":"..."}}` | Writes key files into `PARMANA_KEY_DIR` at startup for any key file that does not already exist. Use it on platforms where you cannot mount files. An invalid value stops the server. | | `KEY_PROVIDER` | No | `local` | `local`, `aws-kms` | Where the signing key lives. `local` reads PEM files. `aws-kms` signs inside AWS KMS so the key is never released. The values `azure-key-vault`, `gcp-kms` and `hsm` pass validation but have no implementation, and the server refuses to start with them. | | `AWS_REGION` | If `KEY_PROVIDER` is `aws-kms` or `PARMANA_SECRETS_PROVIDER` is `aws-secrets-manager` | None | An AWS region such as `ap-south-1` | The AWS region for KMS and Secrets Manager calls. | | `AWS_ROLE_ARN` | If `KEY_PROVIDER` is `aws-kms` or `PARMANA_SECRETS_PROVIDER` is `aws-secrets-manager` | None | An IAM role ARN | The role the server assumes to call AWS, using the Vercel OIDC token. It never uses a static access key. | | `PARMANA_VERIFICATION_KEY_ID` | No | `default` | Key id | The key id that signs and verifies Execution Trust Records. | | `PARMANA_VERIFICATION_SECONDARY_KEY_ID` | No | `default-secondary` | Key id | The key id for the second signature when `CRYPTO_MODE` is `hybrid`. | | `PARMANA_GATEWAY_ID` | No | `parmana-gateway` | Letters, digits, `.`, `_` and `-` | The identity the gateway presents to connectors. An invalid value stops the server. | | `PARMANA_GATEWAY_KEY_ID` | No | `gateway` | Letters, digits, `.`, `_` and `-` | The key id of the gateway attestation key file in `PARMANA_KEY_DIR`. An invalid value stops the server. | | `SIGNING_READINESS_CHECK` | No | Enforced | `true` only has an effect, and only in `test` and `development` | Before each action is released, proves that the evidence signing path works, and refuses with `503 SIGNING_UNAVAILABLE` if it does not. In production it is always on and this variable is ignored. Under KMS the probe uses `kms:Sign` and `kms:GetPublicKey` at most once a minute per instance. | | `EXECUTION_INTENTS_CHECK` | No | Enforced | `true` only has an effect, and only in `test` and `development` | Before each action is released, signs and stores an Execution Intent, and refuses with `503 EXECUTION_INTENT_UNAVAILABLE`, releasing nothing, if it cannot. In production it is always on and this variable is ignored. It needs the `execution_intents` table, so apply migration `20260921120000` before deploying. Under KMS it costs one more `kms:Sign` per released action. See [Execution Intents](/concepts/execution-intents). | Generate the key files with: ```bash npx tsx scripts/generate-keypair.ts --algorithm ed25519 --key-id default npm run generate:gateway-keys ``` Both write into `PARMANA_KEY_DIR`, or `./keys` if it is unset, and refuse to overwrite existing files unless you add `--force`. Keep the private files secret, and never commit them. ## Cryptography | Variable | Required | Default | Values or format | What it does | | ------------------------------ | ---------------------------- | --------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PRIMARY_SIGNATURE_PROVIDER` | No | `ed25519` | `ed25519`, `dilithium3` (alias `ml-dsa-65`) | The signature algorithm. Only these two are implemented. The values `ecdsa-p256`, `dilithium5` and `sphincs-plus` pass validation but have no implementation. `dilithium3` needs Node 24 or later and produces randomized signatures. | | `SECONDARY_SIGNATURE_PROVIDER` | If `CRYPTO_MODE` is `hybrid` | None | Same values as above | The second algorithm for hybrid signing. | | `CRYPTO_MODE` | No | `single` | `single`, `hybrid`, `pq` | `hybrid` adds a second signature from `SECONDARY_SIGNATURE_PROVIDER`. `single` and `pq` behave the same today, because only `hybrid` changes behavior. `hybrid` needs `KEY_PROVIDER=local` and both key pairs (`default` and `default-secondary`) in `PARMANA_KEY_DIR`; the server refuses to start otherwise, and refuses `hybrid` with `KEY_PROVIDER=aws-kms`. | | `HASH_PROVIDER` | No | `sha256` | `sha256` | The hash algorithm. The values `sha3-512` and `blake3` pass validation, but no implementation is registered, so the server fails with "Unknown hash provider" the first time it hashes. | | `HYBRID_SIGNATURE_REQUIRED` | No | Off | Only the exact string `true` turns it on | When on, a record without a valid hybrid signature is rejected instead of passing on its classical signature alone. | ## Authorization and receipts | Variable | Required | Default | Values or format | What it does | | ------------------------------------- | -------- | ------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `EXECUTION_AUTHORIZATION_TTL_SECONDS` | No | `120` | Positive number of seconds | How long a signed execution authorization stays valid. The gateway rejects any authorization whose lifetime is more than 300 seconds, so do not set this above 300. | | `TRUST_PROFILE` | No | `v1` | `v1` | The trust profile. `v1` is the only value. | | `RECEIPT_VERSION` | No | `1` | A string | The version stamped on receipts. | ## Rate limits | Variable | Required | Default | Values or format | What it does | | ------------------------------------ | -------- | ------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `RATE_LIMIT_EXECUTE_PER_MINUTE` | No | `30` | Integer | Requests per minute per authenticated caller on `POST /execute`. Over the limit the API returns `429` with a `Retry-After` header. | | `RATE_LIMIT_HEALTH_PER_MINUTE` | No | `300` | Integer | Requests per minute per IP address on `GET /health` and `GET /ready`. | | `RATE_LIMIT_PUBLIC_PER_MINUTE` | No | `60` | Integer | Requests per minute per IP address, shared across `POST /refusal/verify`, `POST /execution-intents/verify`, `POST /audit/verify` and `/handbook`, the unauthenticated routes that check signatures or write data (G-78), and key discovery (`GET /keys/:keyId`, `GET /.well-known/jwks.json`). | | `RATE_LIMIT_AUTH_FAILURE_PER_MINUTE` | No | `30` | Integer | Failed caller authentications (`401`) per minute per IP address, on every authenticated route. Each one writes a signed audit event, so this stops a stream of bad keys from using up signing capacity. Requests with a valid key do not count. Over the limit, every request from that address gets `429` until the minute resets. | With `DATABASE_URL` set, the counters are shared across instances. Without it they are per process, so the effective limit is the value multiplied by the number of instances, and the server logs `rate_limit_store_not_durable`. ## Approval notifications | Variable | Required | Default | Values or format | What it does | | ------------------------- | ------------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `APPROVAL_WEBHOOK_URL` | With the next one | None: nothing is sent | An https URL (http only in `test` or `development`) | Where to POST an `approval.needed` event when a refused request would have been authorized with a signed approval. See [Human approval](/concepts/human-approval#get-notified). | | `APPROVAL_EMAIL_TO` | For email notifications | None: no email | Email addresses, separated by commas | Who is emailed each `approval.needed` event, through Resend. Needs `RESEND_API_KEY` and a sender, or the server stops at startup. | | `APPROVAL_EMAIL_FROM` | No | `Parmana approvals ` | A sender, such as `Ops ` | The sender. Its domain must be verified in Resend. | | `RESEND_API_KEY` | With `APPROVAL_EMAIL_TO` | None | Set by the Resend integration | The Resend API key. | | `RESEND_EMAIL_DOMAIN` | With `APPROVAL_EMAIL_TO`, unless `APPROVAL_EMAIL_FROM` is set | None | Set by the Resend integration | The verified sending domain. | | `APPROVAL_WEBHOOK_SECRET` | With `APPROVAL_WEBHOOK_URL` | None | A random string, or a secret reference | Signs each event (HMAC SHA256). See `PARMANA_SECRETS_PROVIDER`. Setting only one of the two stops the server at startup. | ## Browser access and the public sandbox [ADR-0014](https://github.com/pavancharak/parmana/blob/main/docs/adr/ADR-0014-Public-Sandbox-And-Docs-Playground.md). Production sets none of these except, if it wants a browser console, `PARMANA_CORS_ORIGINS`. | Variable | Required | Default | Values or format | What it does | | -------------------------------------- | --------------------------- | -------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PARMANA_CORS_ORIGINS` | No | None: no CORS header | Origins separated by commas, each exactly `https://host[:port]`, no path or slash | Lets a browser on these origins call the API. Only a listed origin is named in `Access-Control-Allow-Origin`, never `*`; a preflight from any other origin gets `403`. The key is still required on every request that needs one. | | `PARMANA_SANDBOX` | No | Off | `true`, `false` or unset | Sandbox mode: mounts `POST /sandbox/approvals`, the demo approver. Refused at startup when caller authentication is disabled or any connector variable below (Paytm, HubSpot, Slack, GitHub, including `PARMANA_GITHUB_VERCEL_CONNECT_CONNECTOR_ID`) is defined, even empty, so a sandbox can never reach a real system. **Never set it in production.** | | `PARMANA_SANDBOX_APPROVER_ID` | With `PARMANA_SANDBOX=true` | None | The approver id, such as `sandbox-demo-approver` | The demo approver. Its public key must be added in the sandbox through an approver change, like any approver. Set without `PARMANA_SANDBOX=true`, the server refuses to start. | | `PARMANA_SANDBOX_APPROVER_KEY_ID` | With `PARMANA_SANDBOX=true` | None | The key id, such as `sandbox-demo-approver-key-1` | The demo approver's key id. | | `PARMANA_SANDBOX_APPROVER_PRIVATE_KEY` | With `PARMANA_SANDBOX=true` | None | An Ed25519 private key, PEM; one line with literal `\n` is accepted | Signs the demo approvals. A sandbox key only: it must never be a production approver's key. | ## Local test approver No agent action is authorized without a signed human approval, so a local test server needs an approver it trusts. | Variable | Required | Default | Values or format | What it does | | --------------------------------------- | ------------------- | ----------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PARMANA_TEST_APPROVER_PUBLIC_KEY_FILE` | Never in production | None: not trusted | A path to an Ed25519 public key PEM file | Trusts the approver `local-test-approver`, key `local-test-approver-key-1`, with this key, for the [Quickstart](/quickstart). Honored only when `NODE_ENV` is `test`. Set with any other `NODE_ENV`, the server refuses to start. Real approvers are added through [Manage approvers](/guides/manage-approvers). | ## Secrets | Variable | Required | Default | Values or format | What it does | | -------------------------- | -------- | ------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `PARMANA_SECRETS_PROVIDER` | No | `env` | `env`, `aws-secrets-manager` | How connector credential variables are read. With `env`, the value of a variable such as `HUBSPOT_PRIVATE_APP_TOKEN` **is** the secret. With `aws-secrets-manager`, the value is the **name or ARN of a secret** whose `SecretString` holds the credential, fetched at first use and cached in memory for one hour, never written to disk. | ## Connectors A connector registers only when its credentials are present. A missing connector does not stop the server. It logs an event such as `slack_connector_unavailable` and requests for that connector's actions fail. ### Paytm refund connector | Variable | Required | Default | Values or format | What it does | | ------------------------------- | ------------------------------------ | ------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PAYTM_CONNECTOR_URL` | Both this and the secret, or neither | None | A URL that starts with `https://` | The URL of the separate `parmana-paytm-agent` service that performs the refund. Outside tests, a URL that does not start with `https://` stops the server at startup. | | `PAYTM_CONNECTOR_SHARED_SECRET` | Both this and the URL, or neither | None | A string | The shared secret Parmana sends to that service. Setting only one of the two stops the server at startup. | | `PAYTM_CONNECTOR_TIMEOUT_MS` | No | `10000` | Positive number of milliseconds | How long Parmana waits for that service before failing the call. A value that is not a positive number stops the server. | ### HubSpot connector | Variable | Required | Default | Values or format | What it does | | -------------------------------------- | ----------------------------- | --------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `HUBSPOT_PRIVATE_APP_TOKEN` | For the connector to register | None | A HubSpot private app token, or a secret reference | The credential. See `PARMANA_SECRETS_PROVIDER`. | | `HUBSPOT_BASE_URL` | No | The HubSpot API | A URL | Overrides the API base URL, for testing. | | `HUBSPOT_PRIVATE_APP_TOKEN_ROTATED_AT` | No | None | An ISO 8601 date | When the token was last rotated. The server logs a warning when the token is older than 90 days, or when this is set to something that is not a date. | ### Slack connector | Variable | Required | Default | Values or format | What it does | | --------------------------- | ----------------------------- | --------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | `SLACK_BOT_TOKEN` | For the connector to register | None | A Slack bot token, or a secret reference | The credential. See `PARMANA_SECRETS_PROVIDER`. | | `SLACK_BASE_URL` | No | The Slack API | A URL | Overrides the API base URL, for testing. | | `SLACK_ALLOWED_CHANNEL_IDS` | For any post to succeed | None: every post is refused | Comma separated channel ids, such as `C0123456789,C0987654321` | The only channels a message may go to. The caller's `channelAuthorized` is not trusted (G-76). | ### GitHub connector | Variable | Required | Default | Values or format | What it does | | -------------------------------------------- | ------------------------------------------------ | -------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `GITHUB_APP_ID` | For the connector to register, with the next two | None | A GitHub App id | The GitHub App used to act on pull requests. | | `GITHUB_INSTALLATION_ID` | With the other two | None | An installation id | The installation of that App. | | `GITHUB_APP_PRIVATE_KEY` | With the other two | None | A PEM private key, or a secret reference | The App's private key. | | `GITHUB_BASE_URL` | No | The GitHub API | A URL | Overrides the API base URL, for GitHub Enterprise or testing. | | `PARMANA_GITHUB_VERCEL_CONNECT_CONNECTOR_ID` | No | None | A Vercel Connect connector id | An alternative credential source using Vercel Connect. If it is set, it takes precedence and the three variables above are not used. | ## Variables that look relevant but the server does not read | Variable | Where you may see it | Meaning | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY` | `.env.example`, CI, tutorials | The server does **not** read these. It talks to Postgres only through `DATABASE_URL`. They are used by some tests and tutorials. Setting them on the server has no effect. | | `TEST_GITHUB_APP_ID`, `TEST_GITHUB_INSTALLATION_ID`, `TEST_GITHUB_APP_PRIVATE_KEY`, `TEST_HUBSPOT_PRIVATE_APP_TOKEN`, `TEST_SLACK_BOT_TOKEN`, `TEST_PAYTM_CONNECTOR_SHARED_SECRET` | Tests | Read only when `NODE_ENV` is `test`. Never set them in production. | | `TEST_GITHUB_PULL_NUMBER`, `TEST_GITHUB_REPOSITORY`, `TEST_HUBSPOT_DEAL_ID` | Tests | Test fixtures for live integration tests. | | `ALLOW_LIVE_GITHUB`, `ALLOW_LIVE_HUBSPOT`, `ALLOW_LIVE_SUPABASE` | Tests | Opt in switches for tests that call real services. Never set them in production. | ## Startup errors and what to do The server stops at startup, before it accepts any request, when a required setting is missing or wrong. This is deliberate: a misconfigured server never runs half configured. | The error says | Cause | Fix | | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `PARMANA_POLICY_DIR is not set` | No policy directory | Set `PARMANA_POLICY_DIR` to the directory that holds your policies. | | `No caller authentication keys are configured` | `PARMANA_API_KEYS` is empty or unset | Generate at least one entry and set it. For local development only, set `PARMANA_AUTH_DISABLED=true`. | | `PARMANA_API_KEYS is not valid JSON` or `must be a JSON array` | Malformed value | Fix the JSON. It must be an array of objects with `callerId` and a 64 character lowercase hex `keyHash`. | | `PARMANA_KEY_DIR is not set` | No key directory | Set `PARMANA_KEY_DIR`. | | `Signing key material for keyId "default" is missing` | No `default.private.pem` or `default.public.pem` in `PARMANA_KEY_DIR` | Generate the key pair, mount the files, or set `PARMANA_KEY_MATERIAL_JSON`. | | `Durable NonceStore requires DATABASE_URL` (or `CallerAuditSink`, `ExecutionAuditSink`, `ApprovalNonceStore`, `PolicyChangeStepUpNonceStore`) | `DATABASE_URL` is not set and `NODE_ENV` is not `test` | Set `DATABASE_URL` to a direct Postgres connection string. | | `PARMANA_STORAGE=supabase requires DATABASE_URL` | Supabase storage without a connection string | Set `DATABASE_URL`. | | `Invalid PARMANA_STORAGE: ` (or `CRYPTO_MODE`, `HASH_PROVIDER`, `KEY_PROVIDER`, `PARMANA_SECRETS_PROVIDER`, `TRUST_PROFILE`, `SIGNATURE_PROVIDER`) | A value outside the allowed list | Use one of the values in the tables above. | | `DATABASE_PROVIDER is no longer read` | The removed variable is set | Delete it and set `PARMANA_STORAGE`. | | `KEY_PROVIDER= is not implemented` | `azure-key-vault`, `gcp-kms` or `hsm` | Use `local` or `aws-kms`. | | `KEY_PROVIDER=aws-kms is configured, but no KMS key named "default" is reachable` | KMS key missing, wrong region or role, or no permission | Check `AWS_REGION`, `AWS_ROLE_ARN`, the key alias `default`, and that the role can use the key. | | `PAYTM_CONNECTOR_URL and PAYTM_CONNECTOR_SHARED_SECRET must both be set, or both left unset` | Only one of the pair is set | Set both, or remove both. | | `Invalid PARMANA_GATEWAY_ID` | The id has characters other than letters, digits, `.`, `_` and `-` | Use only those characters. | | `PARMANA_KEY_MATERIAL_JSON is not valid JSON` | Malformed value | Fix the JSON. It must be an object keyed by key id, each with `privateKeyPem` and `publicKeyPem`. | | `PARMANA_SANDBOX=true refuses to start while a built in connector is configured` | A connector variable is defined on a sandbox, even empty | Delete every Paytm, HubSpot, Slack and GitHub variable from the sandbox, and `PARMANA_GITHUB_VERCEL_CONNECT_CONNECTOR_ID`. | | `PARMANA_SANDBOX=true needs the demo approver` or `... refuses to start with PARMANA_AUTH_DISABLED=true` | Sandbox mode is incomplete | Set the three `PARMANA_SANDBOX_APPROVER_*` variables, and keep caller authentication on. | | `PARMANA_SANDBOX_APPROVER_... set without PARMANA_SANDBOX=true` | A demo approver variable outside a sandbox | Remove it. The demo approver exists only in a sandbox. | | `PARMANA_CORS_ORIGINS: "" must be exactly an origin` | A path, a trailing slash or `*` | List bare origins such as `https://docs.parmanasystems.com`. | | `Unknown hash provider: ` | `HASH_PROVIDER` is `sha3-512` or `blake3` | Use `sha256`. | ## See also Continue with [Production deployment](/deployment/production) or [Local deployment](/deployment/local). For hosting choices read [Deploy patterns](/guides/deploy-patterns), and when a startup fails read [Troubleshooting](/troubleshooting). --- ## Security Source: https://docs.parmanasystems.com/security/overview This page is a direct mirror of `docs/CLAIMS.md` sections 3 and 5, read that file for the authoritative version. ## Claims that hold, with their scope **Non-bypassable envelope verification (Conditional Claim 3.1).** For any system running `@parmana/envelope-verifier`, execution requests not authorized by Parmana are cryptographically impossible to accept, **only** for a receiving system that (a) runs the verifier and (b) gates every execution-triggering code path behind its result, and only while Parmana's authorization signing key is not compromised: whoever holds that key, or can make the signing service sign, can produce authorizations the verifier accepts. Parmana enforces nothing at the network level. See the [Audit guide](/evaluation/audit-guide) for what an attacker controls in each case. The default local server does both (a) and (b) for each connector it registers, see [The gateway](/concepts/the-gateway) for the mechanism. A new built in connector is a code change; an [external connector](/guides/connect-any-external-system) is registered through maker checker with no code change, and Parmana signs each release to it. **Fleet wide single use requires a shared NonceStore (3.2).** Single use enforcement is scoped to whichever `NonceStore` instance performs the check. Parmana's own server shares one in Postgres (`SupabaseNonceStore`, and the same for approvals), so every instance on one database accepts an authorization once, and it fails closed at startup without one. A receiving system that runs `@parmana/envelope-verifier` with its own per instance store can accept the same authorization once per instance; `MemoryNonceStore`, for tests, loses all state on restart. Every envelope's bounded TTL limits, but does not eliminate, that exposure. **Durable, third-party-verifiable refusal and audit records (3.11).** Every policy rejection produces a signed Refusal Record, independently verifiable at `POST /refusal/verify` with no API key or database lookup required, just the artifact and Parmana's public key. Production caller-authentication audit events are signed the same way, verifiable at `POST /audit/verify`. Two scope caveats: Refusal Record writing is evidentiary and fails open by design (a rejection is never blocked or delayed by it; only the durable record can go silently missing, never the correctness of the rejection itself), and only production (Supabase) audit sinks sign; the in-memory test sinks don't. **Hybrid (Ed25519 + ML-DSA-65) signing capability, not yet deployed (3.13).** Trust Records and Receipts can be dual-signed with both a classical and a post-quantum algorithm at once, with verification requiring both signatures independently, fail-closed. This is a built, tested capability (`CRYPTO_MODE=hybrid` is real, validated configuration), but it is opt-in and not running anywhere today: every deployed environment signs Ed25519 alone. **Third-party verification:** `@parmana/sign` 0.2.0 and later, the public verification SDK on npm, recognizes the hybrid envelope: `verifyExecutionTrustRecordOffline` checks the record hash, the legacy Ed25519 signature, and every entry in `signatures`, offline, with only the record and the public keys. This repository's own `packages/crypto/src/OfflineVerifier.ts` (TypeScript) does the same, and `python/parmana/crypto/offline_verifier.py` (Python) checks Ed25519 only. **Hybrid-signature downgrade resistance, opt-in.** A genuinely hybrid-signed record's `signatures` array (the ML-DSA-65 half) can be stripped entirely with no cryptographic trace: `schemaVersion` and `signatures` are outside the hashed content by design (so the legacy Ed25519 signature keeps verifying pre-hybrid-era records unchanged), and verification silently falls back to the classical signature alone whenever the array is absent. An opt-in `HYBRID_SIGNATURE_REQUIRED` flag closes this: when set, a record missing its `signatures` array is rejected outright, no fallback. Off by default, so this protects only a deployment that turns it on once every record it issues going forward is genuinely hybrid-signed. ## Claims Parmana intentionally does not make Not "not yet" items, no implementation could honestly back these, unscoped: - Execution is impossible to bypass under all circumstances. - Mathematical proof of execution correctness. - Cryptographic proof of every aspect of runtime behavior. - Guaranteed regulatory compliance. - Absolute prevention of all unauthorized execution. - Tamper-proof operation in every deployment environment. - "Non-bypassable" or "the single execution authority" as an unscoped, system-wide claim, envelope verification is opt-in per receiving endpoint (see 3.1 above). - Deterministic signature output for ML-DSA-65, those signatures are randomized by design; only verification is deterministic. ## Known incidents **Policy-evaluation signals were not bound to the executed Intent (2026-07-28, RESOLVED).** The most severe gap found in this project's history: a live, reproducible bypass of the core "no unauthorized execution" invariant. `PolicyEngine` evaluated whatever signals a caller declared in the request body; `ExecutionGateway` signed and executed `intent.target`/ `intent.parameters`, a completely disjoint set of fields nothing cross-checked. A caller could declare signals describing a small, fully-verified action while `intent` executed something else entirely, and still receive a signed, `APPROVED` Execution Trust Record for it. Live proof-of-concept: `signals` declared a fully verified, policy-approved **$5,000** payment to a known vendor, while `intent`, the part that actually executes, targeted an **attacker-controlled account for $999,999,999**. Before the fix: `200`, policy decision `APPROVED`, execution `COMPLETED`, a real signed trust record and receipt issued for it, the exact artifacts this project's independently-verifiable-execution claim rests on, attesting to something that never happened as described. A related, compounding finding: `authority.principalId` (who the trust record says approved the action) was likewise caller-declared with no binding to the identity `callerId` actually proves, any caller holding any valid API key could claim to be any human or role, including a successful "impersonate the CEO" proof-of-concept. Found via an external adversarial security exercise, not this project's own internal audit process. Fixed by `Policy.boundSignals` + `SignalIntentBinder` (binds specific signal keys to `intent` dot-paths, checked before `PolicyEngine.evaluate`; see [Policies and the decision](/concepts/policies-and-the-decision) for the mechanism), `isPrincipalAllowed` (binds `authority.principalId` to the authenticated `callerId`), and a compounding IDOR fix, `isOwnedByCaller` (any caller could previously read any other caller's complete transaction/trust-record/receipt history). 28 regression tests reproduce the exact live exploit shapes and assert they're now rejected, plus positive controls proving legitimate requests are unaffected. One thing this fix deliberately does not cover, stated plainly rather than left implicit: `boundSignals` only binds the specific fields a policy author declares, it does not independently verify that an _unbound_ signal (`vendorVerified`, `riskScore`, and similar) is actually true; those remain caller-declared attestations. **The default signing key committed to this repository before 2026-07-05** was publicly exposed in the public GitHub repository and must be treated as permanently compromised, all signatures produced by that key are void for authenticity purposes regardless of when signed. The key pair was rotated on 2026-07-05. **Four ML-DSA-65 (Dilithium3) private signing keys committed to a feature branch during the post-quantum signature provider work** were confirmed, by raw key-byte comparison, to be distinct from any key ever trusted by production or local configuration. Unlike the incident above, no signature produced by this key pair was ever accepted as authentic by this codebase. A fresh keypair was generated regardless, the feature branch's history purged, and the exposure independently verified across every remote hosting this repository's history. ## Caller authentication, principal binding, and ownership scoping: three separate layers `packages/api` gates every route behind a caller bearer key, except the liveness/readiness and documentation/verification routes listed on [Authentication](/api-reference/authentication). Three distinct claims, not one, and it matters which one you're relying on: - **Route access is scoped by kind of caller, not per route.** There is no per key allowlist of endpoints, but every governance route (policy changes, approvers, external connectors, the Execution Intent list and finalize) refuses a key not provisioned as a human, with `403 NON_HUMAN_CALLER_DENIED`, and `POST /execute` refuses a capability the key may not invoke (`403 CAPABILITY_NOT_ALLOWED`). - **Principal assertion is scoped**, the fix described in "Known incidents" above. A caller may only submit a transaction whose `authority.principalId` is itself, or an identity explicitly granted via `ApiKeyEntry.allowedPrincipalIds`, never "anything," the default with no grant configured is "only itself." `isPrincipalAllowed`, `packages/api/src/auth/isPrincipalAllowed.ts`. - **Data ownership is scoped**, the same fix. A caller can no longer read another caller's transactions, trust records, or receipts, `metadata.submittedBy` is stamped server-side from the authenticated caller (never trusted from the client), and `isOwnedByCaller` gates every read route by it; cross-caller access reads as a clean `404`, not a `403` that would confirm the target id exists. `packages/api/src/auth/isOwnedByCaller.ts`. None of this is mutual TLS (there is none by default), and key rotation is still a manual operator procedure with no self-service endpoint, see [Deploy patterns](/guides/deploy-patterns#caller-authentication). This authentication layer is independent of, and does not substitute for, [Execution Authorization](/concepts/execution-authorization) or [gateway attestation](/concepts/gateway-attestation). ## Automated checks on the repository Every pull request to `main` in [github.com/pavancharak/parmana](https://github.com/pavancharak/parmana) must pass these before it can merge. `main` is protected: no direct pushes, no force pushes, no deletion. | Check | What it does | | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `build-and-test` | Build, lint, typecheck and the full test suite | | `verify-policy-approvals` | Fails if any policy file in the tree has no matching maker checker approval record for its exact content | | CodeQL (`Analyze (javascript-typescript)`, `Analyze (python)`) | Static analysis for security bugs, on every pull request, on push to `main` and weekly | | `dependency-review` | Blocks a pull request that adds a dependency with a known vulnerability | Also enabled on the repository: - [OpenSSF Scorecard](https://scorecard.dev/viewer/?uri=github.com/pavancharak/parmana): a public supply chain security score, recomputed weekly and on every push to `main`. - [OpenSSF Best Practices](https://www.bestpractices.dev/projects/15230): the project's answers to the OpenSSF Best Practices criteria. The badge is "in progress", not "passing": the passing level requires the whole project to be open source, and only the SDKs are. - Dependabot alerts and grouped weekly update pull requests for npm, Python, the Dockerfile and GitHub Actions. - GitHub secret scanning with push protection, which blocks a commit that contains a known secret format. - Private vulnerability reporting, alongside the email route in [SECURITY.md](https://github.com/pavancharak/parmana/blob/main/SECURITY.md). - GitHub Actions and Docker base images pinned to exact commits and digests, and workflow tokens limited to read access. - Signed build provenance and a CycloneDX SBOM for every SDK release, and signed build provenance for the server image published with each release (below). - Mutation testing of the security-critical packages, run by hand (`npm run mutation`). The scores, the gaps found and what was fixed are in [docs/MUTATION-TESTING.md](https://github.com/pavancharak/parmana/blob/main/docs/MUTATION-TESTING.md). - Fuzz tests (property based, with fast-check) of the API request boundary, policy loading and evaluation, the offline verifier and approval parsing, run with every `npm test`. They found three crashes on malformed input, all fixed (G-87, G-90, G-91 in `docs/VERIFICATION-GAPS.md`). - Every open issue, with what to do meanwhile: [Limitations](/security/limitations#open-issues). - Each risk in the OWASP Top 10 for LLM Applications and the OWASP Top 10 for Agentic Applications (2026), with what Parmana does and the evidence: [OWASP mapping](/security/owasp-mapping). - Human oversight and record keeping under the EU AI Act (Articles 12, 14, 26), the NIST AI RMF, ISO/IEC 42001 and RBI guidance: [Regulation mapping](/security/regulation-mapping). ### Verifying an SDK package from a GitHub release Each [GitHub release](https://github.com/pavancharak/parmana/releases) of the SDKs carries the four packages built by GitHub Actions from the release's tag, and a `multiple.intoto.jsonl` file: [SLSA](https://slsa.dev) build provenance, signed with GitHub's identity for the release workflow, recording the commit, the workflow and each file's SHA-256. To check a downloaded package with [slsa-verifier](https://github.com/slsa-framework/slsa-verifier): ```bash slsa-verifier verify-artifact parmana-sdk-1.6.0.tgz \ --provenance-path multiple.intoto.jsonl \ --source-uri github.com/pavancharak/parmana \ --source-tag sdk-v1.6.0 ``` The provenance covers the files attached to the release. From 1.6.0 on, the files published to npm and PyPI are the release's own files, so a registry download has the same hash as the release file and the provenance covers it too: `@parmana/sdk` 1.6.0, `@parmana/connector-sdk` 0.3.0 and `parmana` 1.6.0 are the files of release `sdk-v1.6.0`. Earlier versions (`@parmana/sdk` and `parmana` 1.5.0 and before, and `parmana-connector-sdk` 0.2.0, still the current version) were built before this workflow existed, so they are not byte for byte the files in a release, although they come from the same source. Each npm package and each wheel also has a [CycloneDX](https://cyclonedx.org) 1.5 SBOM beside it, named after it (for example `parmana-sdk-1.6.0.tgz.cdx.json`): the package, and every dependency installed with it, with versions and package URLs. For the npm packages it lists runtime dependencies only; for `parmana` it includes the optional `verify` extra (`cryptography`). The versions are the ones resolved when the release was built; a later install can resolve newer versions within the declared ranges. The provenance covers the SBOM files too, so the same `slsa-verifier` command checks them. Feed an SBOM to a scanner such as [Grype](https://github.com/anchore/grype) or [OSV-Scanner](https://github.com/google/osv-scanner) to check the dependencies for known vulnerabilities. ### Verifying the server image From the first release published after 2026-10-06, each release also builds the API server from the repository's `Dockerfile` and pushes it to the GitHub Container Registry as `ghcr.io/pavancharak/parmana-api:`, with SLSA build provenance attached to the image in the registry. To check an image before you run it: ```bash slsa-verifier verify-image ghcr.io/pavancharak/parmana-api@sha256: \ --source-uri github.com/pavancharak/parmana \ --source-tag ``` Verify by digest, not by tag: a tag can be moved, a digest cannot. Then run that digest. The provenance covers this image only. The hosted API and the public sandbox are built by Vercel from the same repository, not from this image, and carry no SLSA provenance. These are hygiene checks on the code and its supply chain. They do not replace a review of the enforcement design; see the [Audit guide](/evaluation/audit-guide) for that. ## Where hardening work is tracked KMS/HSM key custody, credential brokering, and network-level enforcement are real, specific, sequenced plans, not vague future promises. See [Roadmap](/roadmap). --- ## Troubleshooting Source: https://docs.parmanasystems.com/troubleshooting ## `npm run