Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
349 changes: 9 additions & 340 deletions docs/connectors/CONNECTING_AN_AGENT.md

Large diffs are not rendered by default.

101 changes: 55 additions & 46 deletions docs/site/guides/connect-an-agent.mdx

Large diffs are not rendered by default.

55 changes: 36 additions & 19 deletions docs/site/guides/deploy-patterns.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,24 +30,30 @@ switch (configuration.provider) {
case "memory":
return new MemoryStorageProvider();
case "supabase":
return new SupabaseStorageProvider();
case "postgres":
throw new Error("Postgres storage provider not implemented.");
// both need DATABASE_URL, and refuse to start without it
return new SupabaseStorageProvider();
case "sqlite":
throw new Error("SQLite storage provider not implemented.");
}
```

Only `memory` and `supabase` have a real class behind them, `postgres` and `sqlite` are
declared in the type union and throw immediately at startup, by design, fails closed rather
than silently falling back to something else.
`memory` keeps everything in the process and loses it on restart. `supabase` and `postgres`
are the same implementation: despite its name, `SupabaseStorageProvider` needs only a Postgres
connection string, so a self hosted Postgres works under either name (G-57). Both refuse to
start without `DATABASE_URL`. `sqlite` is declared and throws at startup, by design: it fails
closed rather than silently falling back to something else.

```bash
PARMANA_STORAGE=memory # in-process, non-persistent, no external dependency
PARMANA_STORAGE=memory # in process, non persistent, no external dependency
# or
PARMANA_STORAGE=supabase # this repo's committed .env default
PARMANA_STORAGE=supabase # or postgres; needs DATABASE_URL and every migration applied
DATABASE_URL=postgresql://user:password@host:5432/parmana
```

Apply the migrations before the first start: `npm run db:migrate -- apply` (status with
`npm run db:migrate -- status`). See [Production deployment](/deployment/production).

`PARMANA_STORAGE` is the only variable read for this. An older `DATABASE_PROVIDER` variable
existed briefly as a second, disconnected path to the same setting and is now retired: if
it's present in the environment, config loading fails at startup naming `PARMANA_STORAGE` as
Expand All @@ -71,10 +77,13 @@ if you sign with the wrong one for a configured provider.
### 3. Generate a caller API key for every system that will call this API

```bash
npm run generate:api-key -- --caller-id orchestrator-1
npm run generate:api-key -- --caller-id orchestrator-1 --allowed-capabilities "paytm:refund"
```

Prints a raw key once, and the hash to add to `PARMANA_API_KEYS`. The raw key is never
Prints a raw key once, and the entry to add to `PARMANA_API_KEYS`. A key with no
`--allowed-capabilities` may invoke nothing (`403 CAPABILITY_NOT_ALLOWED`); a person who
proposes or approves governance changes needs `--credential-holder-type USER` instead, see
[Credentials](/guides/credentials-maker-checker-approver). The raw key is never
written to disk by the script and cannot be recovered afterward, hand it to the calling
system now. Repeat once per caller (your AI orchestration service, an internal dashboard,
anything else that will call this API), and repeat again per caller whenever you rotate a
Expand All @@ -83,16 +92,20 @@ key, see [Rotating a caller's key](#rotating-a-callers-key) below.
### 4. Set the full environment checklist

```bash
PARMANA_STORAGE=memory # or: supabase
PARMANA_STORAGE=memory # or: supabase / postgres, with DATABASE_URL
PARMANA_POLICY_DIR=/absolute/path/to/policies
PARMANA_KEY_DIR=/absolute/path/to/keys
PARMANA_GATEWAY_KEY_ID=gateway # optional, defaults to "gateway"
HUBSPOT_PRIVATE_APP_TOKEN=... # credential for the hubspot connector, omit to leave it unregistered
PRIMARY_SIGNATURE_PROVIDER=ed25519 # or: dilithium3, needs Node >= 24
HASH_PROVIDER=sha256
PARMANA_API_KEYS=[{"callerId":"orchestrator-1","keyHash":"..."}] # from step 3, one entry per key
PARMANA_API_KEYS=[{"callerId":"orchestrator-1","keyHash":"...","allowedCapabilities":["paytm:refund"]}] # from step 3
```

This is the minimum for a local start. Every variable, including `KEY_PROVIDER=aws-kms`,
`PARMANA_SECRETS_PROVIDER`, the connector credentials and the approval webhook, is in
[Environment variables](/deployment/environment-variables).

`PARMANA_API_KEYS` is a JSON array. Multiple entries may share the same `callerId`, that is
how rotation works, see below. The server refuses to start with this unset or empty, unless
`PARMANA_AUTH_DISABLED=true` is set explicitly for local development, see
Expand Down Expand Up @@ -135,8 +148,8 @@ logged or written to disk anywhere in this codebase.

**This is only safe over TLS.** A bearer key sent over plaintext HTTP is readable by anything
that can observe the connection. Terminate TLS in front of this server (a reverse proxy, load
balancer, or service mesh sidecar in your own infrastructure, since Parmana does not run a
hosted service for you), and never expose the API on plaintext HTTP outside `localhost`. This
balancer, or service mesh sidecar in your own infrastructure when you host it yourself; on
Vercel TLS is already terminated for you), and never expose the API on plaintext HTTP outside `localhost`. This
is not a suggestion, it is the entire security value of the bearer-key scheme.

Caller authentication is a distinct layer from everything else Parmana does. It runs first,
Expand All @@ -150,7 +163,8 @@ see [How Parmana thinks](/how-parmana-thinks).

### Rotating a caller's key

No downtime, no restart required beyond a config reload:
No downtime. The new list takes effect when the server reads its configuration again: a
restart, or on Vercel a redeploy:

1. Generate a new key for the same `callerId`: `npm run generate:api-key -- --caller-id
orchestrator-1`.
Expand Down Expand Up @@ -226,14 +240,15 @@ environment-variable toggle for this today, it's a code change.
`azure-key-vault`, `gcp-kms` and `hsm` are declared config values with no implementation.
Setting `KEY_PROVIDER` to one of those fails startup loudly rather than silently running
as if it were `local`.
- **Single process, single instance.** Authorization and approval nonces are kept in Postgres
- **No high availability setup.** Several instances on one database are safe for replay: authorization and approval nonces are kept in Postgres
(`consumed_nonces`, `consumed_approval_nonces`), so several instances on the same database still
accept each one once. Only the test suite (`NODE_ENV=test`) keeps them in memory.
- **Connectors register only when their own credentials are configured.** HubSpot, GitHub,
Paytm, and Slack each check for their own credential environment variable at startup and
register only if it's present; with none configured, every capability fails closed with
`503 CONNECTOR_NOT_REGISTERED`. See [Add a connector with the Connector
SDK](/guides/add-a-connector) for what adding another requires today.
`503 CONNECTOR_NOT_REGISTERED`. Any other HTTPS endpoint can be registered with no code
as an [external connector](/guides/connect-any-external-system); a new built in connector is
a code change, see the [Connector Development Guide](/integrations/connector-development-guide).

## Troubleshoot

Expand All @@ -243,8 +258,10 @@ environment-variable toggle for this today, it's a code change.
before the server starts, `FileKeyProvider` doesn't create it.
- **Config loading fails naming `DATABASE_PROVIDER`.** Remove that variable from your
environment, use `PARMANA_STORAGE` instead, see step 1.
- **`Postgres storage provider not implemented.`** Expected, `postgres` and `sqlite` are
declared but not built, use `memory` or `supabase`.
- **`PARMANA_STORAGE=supabase requires DATABASE_URL`.** Set `DATABASE_URL` to a Postgres
connection string (`postgres` gives the same message).
- **`SQLite storage provider not implemented.`** Expected: use `memory`, `supabase` or
`postgres`.
- **`No caller authentication keys are configured`.** `PARMANA_API_KEYS` is unset or `[]`.
Generate a key (step 3) and add it, or set `PARMANA_AUTH_DISABLED=true` for local
development only.
Expand Down
96 changes: 49 additions & 47 deletions docs/site/guides/live-api-and-demos.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ description: "Call the real, deployed Parmana API directly: authentication, the
---

<Info>
Every endpoint, error, and worked example below has been run against the live
deployment. Base URL: `https://parmana-api-real.vercel.app`.
Production base URL: `https://parmana-api-real.vercel.app`. Checked against
the code and the deployment on 2026-10-02.
</Info>

<Tip>
Expand All @@ -19,9 +19,9 @@ description: "Call the real, deployed Parmana API directly: authentication, the

## Goal

Build a demo by calling a real, running Parmana deployment directly, no local setup, no
mocked responses. This is the real, production `@parmana/api` code, not a sandbox or a
simplified illustration.
Call the real, running production deployment directly, no local setup, no mocked
responses. It runs the same `@parmana/api` code as the sandbox, with real connectors and real
credentials, so it is open only to callers its operator has issued a key.

## What this deployment is, and isn't

Expand All @@ -37,18 +37,21 @@ independent offline verification, and audit trails.
`parmana-paytm-agent` service, which itself calls Paytm's staging API. An approved
`paytm:refund` request reaches Paytm's real staging API and returns a real result, with a
full cross-service audit trail correlated by `businessTransactionId`. Full runbook:
[End-to-end: agent → Parmana → Paytm](/guides/end-to-end-paytm-flow).
[End to end: agent to Parmana to Paytm](/guides/end-to-end-paytm-flow).

**Isn't (still true for HubSpot/GitHub):** no HubSpot token, no GitHub App credentials
configured, and it isn't running in test mode either. This mirrors a real, documented
**Isn't (HubSpot, GitHub, Slack, as last checked by the operator on 2026-09-30):** no HubSpot
token, GitHub App credentials or Slack bot token is configured, and it isn't running in test
mode either. An approved request for one of these answers `503 CONNECTOR_NOT_REGISTERED`, which
is how to check it today. This mirrors a real, documented
finding in this codebase (see [Limitations](/security/limitations)
and the `vendor-payment` policy's own history) that a capability should not be wired to a
connector until its signals are independently verified, not merely caller-declared.

Concretely: an **approved** decision for `hubspot-deal-update`/`github-pr-approval`/etc.
reaches Policy Engine and gets signed, then fails with a `503`
(code `CONNECTOR_NOT_REGISTERED`) at the dispatch stage, since their
credentials aren't configured on this deployment. A **denied** decision never reaches that
Concretely: an **approved** request for `hubspot:deal-update`, `github:pr-merge`, `slack:post-message`
and the other HubSpot and GitHub actions is refused with a `503`
(code `CONNECTOR_NOT_REGISTERED`) before release, and nothing runs, since their
credentials aren't configured on this deployment. A capability registered as an [external
connector](/guides/connect-any-external-system) is released to its own endpoint. A **denied** decision never reaches that
stage (policy rejection happens before dispatch), so it always completes cleanly regardless
of capability. Plan demos around that: "the system correctly declines" is a complete, real
demo for any capability; "money/data actually moves" is real and demonstrable specifically
Expand All @@ -57,31 +60,31 @@ credentials.

## Authentication

Bearer token in the `Authorization` header. Two keys are currently provisioned:
Bearer token in the `Authorization` header. Production has **no public or demo key**: every
key was rebuilt from scratch on 2026-09-27 and again on 2026-09-30, and the operator issues one
per caller, scoped to the capabilities it needs (for example `paytm-refund-agent`, allowed only
`paytm:refund`). To try requests without a key of your own, use the [sandbox](/playground).

| Caller | Capabilities | Use for |
| --------------------------- | ------------------------------- | ---------------------------------------------------------------------- |
| `demo` | `["*"]`, any policy | Building new demos. Use this by default. |
| `agent-vendor-payment-demo` | `["agent-vendor-payment"]` only | Kept for continuity from an earlier session; not needed for new demos. |

Raw key values are shown once in a terminal and never committed to the repo, only their
salted hashes live in the deployment's environment configuration. To mint your own:
Raw key values are shown once in a terminal and never committed to the repo; only their
hashes live in the deployment's environment configuration. To mint a key for an agent, scoped to
its capabilities:

```bash
npx tsx scripts/generate-api-key.ts \
--caller-id my-new-demo \
--allowed-capabilities "*" \
--caller-id my-agent \
--allowed-capabilities "paytm:refund" \
--credential-holder-type SERVICE
```

This prints the raw key once and a config entry to add to the deployment's caller-key list,
then requires a redeploy to take effect. See [Authentication](/api-reference/authentication)
for the full caller-auth model, and [Credentials: maker, checker and approver](/guides/credentials-maker-checker-approver) for the exact commands.
This prints the raw key once and a config entry to add to the deployment's caller key list,
then requires a redeploy to take effect. Never grant `"*"` to an agent. See
[Authentication](/api-reference/authentication) for the full caller auth model, and
[Credentials: maker, checker and approver](/guides/credentials-maker-checker-approver) for the
exact commands.

**Principal scoping is a separate check from capability scoping**: a key with no explicit
principal grant may only assert `authority.principalId` equal to its own caller id.
Simplest path, set `authority.principalId` to `"demo"` in every transaction you submit
with the `demo` key.
Simplest path: set `authority.principalId` to your own caller id.

## The shape of a request

Expand All @@ -92,7 +95,7 @@ with the `demo` key.
"authority": {
"authorityId": "authority-demo",
"authorityType": "SERVICE",
"principalId": "demo",
"principalId": "my-agent",
"issuedAt": "2026-01-01T00:00:00Z"
},
"authorization": {
Expand Down Expand Up @@ -193,29 +196,28 @@ goes live (proposed and approved through policy governance, no deploy) are in

## Endpoints

| Method & path | Auth | What it does |
| ---------------------------- | -------- | ------------------------------------------------------------------------------------------------- |
| `GET /health` / `GET /ready` | none | Liveness / readiness |
| `POST /execute` | required | Submit a Business Transaction, get the decision |
| `GET /transactions` | required | List your own submitted transactions |
| `GET /refusal/:id` | required | The signed Refusal Record for a rejected transaction |
| `POST /refusal/verify` | none | Independently verify a Refusal Record's signature |
| `POST /audit/verify` | none | Verify a signed caller-audit event |
| `GET /keys/:keyId` | none | Fetch a public signing key (PEM + JWK where supported) |
| `GET /.well-known/jwks.json` | none | Every public key this deployment currently holds |
| `GET /trust-records/:id` | required | The full signed Execution Trust Record (only populated once a real connector is wired, see above) |
| Method & path | Auth | What it does |
| ---------------------------- | -------- | ------------------------------------------------------------------------------------------- |
| `GET /health` / `GET /ready` | none | Liveness / readiness |
| `POST /execute` | required | Submit a Business Transaction; `200` returns the signed Trust Record of the executed action |
| `GET /transactions` | required | List your own submitted transactions |
| `GET /refusal/:id` | required | The signed Refusal Record for a rejected transaction |
| `POST /refusal/verify` | none | Independently verify a Refusal Record's signature |
| `POST /audit/verify` | none | Verify a signed caller-audit event |
| `GET /keys/:keyId` | none | Fetch a public signing key (PEM + JWK where supported) |
| `GET /.well-known/jwks.json` | none | Every public key this deployment currently holds |
| `GET /trust-records/:id` | required | The full signed Execution Trust Record of an executed action |

Machine-readable spec: `GET /openapi.yaml` or [the REST API
reference](/api-reference/introduction).

## Verifying a result independently

`GET /trust-records/:id` only returns a record for a capability with a registered
connector, none on this deployment. For a real, fetchable, signed artifact today, use the
**Refusal Record**: fetch it via `GET /refusal/:id`, then verify with zero further server
calls using `verifyExecutionTrustRecordOffline` from `@parmana/crypto` for an
`ExecutionTrustRecord`, or `POST /refusal/verify` (server-side, but still genuinely
cryptographic and unauthenticated) for a `RefusalRecord`. See [Verify a trust record
An executed action has a Trust Record (`GET /trust-records/:id`); a refused one has a
**Refusal Record** (`GET /refusal/:id`). Verify a Trust Record with zero further server calls
using `verifyExecutionTrustRecordOffline` (TypeScript) or `verify_execution_trust_record_offline`
(Python) and the key from `GET /keys/default`, and a Refusal Record with `POST /refusal/verify`
(server side, but cryptographic and unauthenticated). See [Verify a trust record
independently](/guides/verify-independently) for the fully offline path, and
`examples/tutorials/107-offline-verification/` through `110-hybrid-signature-downgrade-protection/`
for runnable, tested demonstrations of every scenario, including a hybrid (Ed25519 +
Expand Down Expand Up @@ -245,7 +247,7 @@ ML-DSA-65) record and a downgrade-attack proof.
href="/guides/connect-an-agent"
>
How an AI agent authenticates, sends a governed request, and handles
APPROVED/REJECTED — the real parmana-phinite-agent integration as the worked
example.
APPROVED/REJECTED, with the real parmana-phinite-agent integration as the
worked example.
</Card>
</CardGroup>
Loading
Loading